The Agent badge showed 2 or 3 while one Claude Code session was connected. The relay log against the open TCP connections in the container showed two causes. - A client may drop its event stream while the relay still asks the API about its token. The close was over before the relay listened for it, so the stream stayed in the list and counted for good. The relay now listens before the token check and keeps no stream that is already gone. It listens on the response, not the request. - Claude Code opens three sessions whenever it connects and keeps a stream on one of them. It never ends the others with a DELETE. Any session counted for two minutes after its last request, so the badge stood at 3 after each start. Now a session counts while its stream is open or it called a tool in the last two minutes. - The relay logs session start, stream open and close and each change of the count, with session ids cut to 8 characters. One case is left: after a relay restart Claude Code sometimes repeats its first call on a spare session. The badge then shows 2 for two minutes and drops to 1 on its own. Tests: - relay.test.ts: 27/27, three new. A dropped stream counts out, a stream dropped during the token check never counts, and spare sessions without a tool call do not count. - Counter-checks: without the early close listener the drop test turns red, with the old count two tests turn red. - Live: the badge showed 2 with one open SSE connection before. After the fix it shows "Agent · 1" and stays there after a normal call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
552 lines
21 KiB
TypeScript
552 lines
21 KiB
TypeScript
import { createServer, type Server } from 'node:http';
|
||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||
import { type ApiAnswer, CLOSE, type Relay, startRelay, VIEW_URI } from '../../tools/mcp/relay.mjs';
|
||
|
||
/**
|
||
* The MCP relay (#429) between a fake API, agents speaking MCP over HTTP, and fake editor tabs on
|
||
* WebSockets: no browser, no PHP.
|
||
*/
|
||
|
||
const ORIGIN = 'https://editor.test';
|
||
const TOOLS = [
|
||
{ name: 'get_view', description: 'view', inputSchema: { type: 'object' } },
|
||
{ name: 'screenshot', description: 'picture', inputSchema: { type: 'object' } },
|
||
];
|
||
const TOKENS: Record<string, string> = { fwa_alice: 'alice', fwa_bob: 'bob', fwa_alice2: 'Alice' };
|
||
|
||
let relay!: Relay;
|
||
let running = false;
|
||
let tickets: Map<string, string>;
|
||
let addresses: string[];
|
||
let throttled: boolean;
|
||
/** How long the fake API takes to know a token. */
|
||
let verifyMs: number;
|
||
const open: WebSocket[] = [];
|
||
|
||
async function start(extra: Partial<Parameters<typeof startRelay>[0]> = {}): Promise<void> {
|
||
if (running) await relay.close();
|
||
relay = await startRelay({
|
||
api: {
|
||
verifyToken: async (token: string, address: string): Promise<ApiAnswer> => {
|
||
addresses.push(address);
|
||
if (verifyMs > 0) await new Promise((resolve) => setTimeout(resolve, verifyMs));
|
||
if (throttled) return { status: 429, retryAfter: '600', error: 'Too many' };
|
||
const user = TOKENS[token];
|
||
return user ? { status: 200, user } : { status: 401 };
|
||
},
|
||
redeemTicket: (ticket: string): Promise<ApiAnswer> => {
|
||
const user = tickets.get(ticket);
|
||
tickets.delete(ticket);
|
||
return Promise.resolve(user ? { status: 200, user } : { status: 401 });
|
||
},
|
||
},
|
||
tools: () => Promise.resolve(TOOLS),
|
||
origins: [ORIGIN],
|
||
...extra,
|
||
});
|
||
running = true;
|
||
}
|
||
|
||
beforeEach(async () => {
|
||
tickets = new Map();
|
||
addresses = [];
|
||
throttled = false;
|
||
verifyMs = 0;
|
||
await start();
|
||
});
|
||
|
||
afterEach(async () => {
|
||
for (const socket of open.splice(0)) socket.close();
|
||
await relay.close();
|
||
running = false;
|
||
});
|
||
|
||
const url = () => `http://127.0.0.1:${relay.port}/mcp`;
|
||
|
||
interface Content {
|
||
type: string;
|
||
text?: string;
|
||
data?: string;
|
||
mimeType?: string;
|
||
}
|
||
interface ToolResult {
|
||
content: Content[];
|
||
isError?: boolean;
|
||
structuredContent?: object;
|
||
}
|
||
interface Reply {
|
||
result?: Record<string, unknown>;
|
||
error?: { code: number; message: string };
|
||
}
|
||
/** What a fake tab hears from the relay. */
|
||
interface TabMessage {
|
||
type: string;
|
||
user?: string;
|
||
count?: number;
|
||
id?: number;
|
||
tool?: string;
|
||
}
|
||
|
||
const toolResult = (body: Reply | null) => body?.result as unknown as ToolResult;
|
||
const textOf = (body: Reply | null, at = 0) => toolResult(body).content[at]?.text;
|
||
const viewText = (body: Reply | null) =>
|
||
(body?.result as unknown as { contents: { text: string }[] }).contents[0]?.text ?? '';
|
||
|
||
async function rpc(
|
||
token: string | null,
|
||
method: string,
|
||
params: object = {},
|
||
session?: string,
|
||
headers: Record<string, string> = {},
|
||
) {
|
||
const res = await fetch(url(), {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Accept: 'application/json, text/event-stream',
|
||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||
...(session ? { 'Mcp-Session-Id': session } : {}),
|
||
...headers,
|
||
},
|
||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
|
||
});
|
||
const text = await res.text();
|
||
return {
|
||
status: res.status,
|
||
headers: res.headers,
|
||
body: (text ? JSON.parse(text) : null) as Reply | null,
|
||
};
|
||
}
|
||
|
||
async function initialize(token: string, protocolVersion = '2025-06-18'): Promise<string> {
|
||
const answer = await rpc(token, 'initialize', {
|
||
protocolVersion,
|
||
capabilities: {},
|
||
clientInfo: { name: 'test' },
|
||
});
|
||
expect(answer.status).toBe(200);
|
||
return answer.headers.get('mcp-session-id') ?? '';
|
||
}
|
||
|
||
interface FakeTab {
|
||
socket: WebSocket;
|
||
received: TabMessage[];
|
||
closed: Promise<number>;
|
||
/** Answers every call with what `answer` makes of it; null leaves it unanswered. */
|
||
answer: (call: TabMessage) => object | null;
|
||
next(type: string): Promise<TabMessage>;
|
||
}
|
||
|
||
function connectTab(
|
||
user: string,
|
||
origin: string | null = ORIGIN,
|
||
ticket = `ticket-${Math.random()}`,
|
||
): FakeTab {
|
||
tickets.set(ticket, user);
|
||
const socket = new WebSocket(url().replace('http', 'ws'), {
|
||
headers: origin ? { Origin: origin } : {},
|
||
} as unknown as string[]);
|
||
open.push(socket);
|
||
const tab: FakeTab = {
|
||
socket,
|
||
received: [],
|
||
// Node's client reports a refused handshake as an error alone, without the close a browser adds.
|
||
closed: new Promise((resolve) => {
|
||
socket.addEventListener('close', (event) => resolve(event.code));
|
||
socket.addEventListener('error', () => resolve(1006));
|
||
}),
|
||
answer: (call) => ({ content: [{ type: 'text', text: `${user} did ${call.tool ?? ''}` }] }),
|
||
next: (type) =>
|
||
new Promise((resolve) => {
|
||
const seen = tab.received.find((message) => message.type === type);
|
||
if (seen) {
|
||
tab.received.splice(tab.received.indexOf(seen), 1);
|
||
resolve(seen);
|
||
return;
|
||
}
|
||
const listener = (event: MessageEvent) => {
|
||
const message = JSON.parse(String(event.data)) as TabMessage;
|
||
if (message.type !== type) return;
|
||
tab.received.splice(tab.received.indexOf(message), 1);
|
||
socket.removeEventListener('message', listener);
|
||
resolve(message);
|
||
};
|
||
socket.addEventListener('message', listener);
|
||
}),
|
||
};
|
||
socket.addEventListener('open', () => socket.send(JSON.stringify({ type: 'hello', ticket })));
|
||
socket.addEventListener('message', (event) => {
|
||
const message = JSON.parse(String(event.data)) as TabMessage;
|
||
if (message.type === 'call') {
|
||
const result = tab.answer(message);
|
||
if (result) socket.send(JSON.stringify({ type: 'result', id: message.id, ...result }));
|
||
return;
|
||
}
|
||
tab.received.push(message);
|
||
});
|
||
return tab;
|
||
}
|
||
|
||
async function onTab(user: string): Promise<FakeTab> {
|
||
const tab = connectTab(user);
|
||
expect((await tab.next('welcome')).user).toBe(user);
|
||
return tab;
|
||
}
|
||
|
||
const call = (token: string, session: string, name: string, args: object = {}) =>
|
||
rpc(token, 'tools/call', { name, arguments: args }, session);
|
||
|
||
describe('agents', () => {
|
||
it('need a token the API knows, and hear 429 while the API brakes them', async () => {
|
||
const none = await rpc(null, 'initialize');
|
||
const wrong = await rpc('fwa_nobody', 'initialize');
|
||
throttled = true;
|
||
const braked = await rpc('fwa_alice', 'initialize');
|
||
expect([none.status, wrong.status, braked.status]).toEqual([401, 401, 429]);
|
||
expect(none.headers.get('www-authenticate')).toMatch(/^Bearer/);
|
||
expect(braked.headers.get('retry-after')).toBe('600');
|
||
});
|
||
|
||
it('pass their own address to the brake, or nginx’s word for it only when told to trust it', async () => {
|
||
await rpc('fwa_alice', 'initialize', {}, undefined, { 'X-Real-IP': '203.0.113.9' });
|
||
await start({ trustProxy: true });
|
||
await rpc('fwa_alice', 'initialize', {}, undefined, { 'X-Real-IP': '203.0.113.9' });
|
||
expect(addresses).toHaveLength(2);
|
||
expect(addresses[0]).not.toBe('203.0.113.9');
|
||
expect(addresses[1]).toBe('203.0.113.9');
|
||
});
|
||
|
||
it('start a session in the protocol version they ask for, or the newest', async () => {
|
||
const asked = await rpc('fwa_alice', 'initialize', { protocolVersion: '2025-03-26' });
|
||
const unknown = await rpc('fwa_alice', 'initialize', { protocolVersion: '1999-01-01' });
|
||
expect(asked.body?.result).toMatchObject({
|
||
protocolVersion: '2025-03-26',
|
||
capabilities: { tools: {}, resources: { subscribe: true } },
|
||
serverInfo: { name: 'feuerwerk' },
|
||
});
|
||
expect(asked.headers.get('mcp-session-id')).toMatch(/^[0-9a-f-]{36}$/);
|
||
expect(unknown.body?.result?.protocolVersion).toBe('2025-11-25');
|
||
});
|
||
|
||
it('get 202 for a notification and the tool list on asking', async () => {
|
||
const session = await initialize('fwa_alice');
|
||
const res = await fetch(url(), {
|
||
method: 'POST',
|
||
headers: {
|
||
Authorization: 'Bearer fwa_alice',
|
||
'Mcp-Session-Id': session,
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }),
|
||
});
|
||
expect(res.status).toBe(202);
|
||
expect((await rpc('fwa_alice', 'tools/list', {}, session)).body?.result).toEqual({ tools: TOOLS });
|
||
});
|
||
|
||
it('cannot use a session of another account, nor one that is not there', async () => {
|
||
const session = await initialize('fwa_alice');
|
||
expect((await rpc('fwa_bob', 'tools/list', {}, session)).status).toBe(404);
|
||
expect((await rpc('fwa_alice', 'tools/list', {}, 'no-such-session')).status).toBe(404);
|
||
expect((await rpc('fwa_alice2', 'tools/list', {}, session)).status).toBe(200);
|
||
});
|
||
|
||
it('are refused from a page of another site, not from the editor’s own', async () => {
|
||
const foreign = await rpc('fwa_alice', 'initialize', {}, undefined, { Origin: 'https://evil.test' });
|
||
const own = await rpc('fwa_alice', 'initialize', {}, undefined, { Origin: ORIGIN });
|
||
expect([foreign.status, own.status]).toEqual([403, 200]);
|
||
});
|
||
|
||
it('hear about unknown tools and methods in JSON-RPC terms', async () => {
|
||
const session = await initialize('fwa_alice');
|
||
expect((await call('fwa_alice', session, 'rm_rf')).body?.error?.code).toBe(-32602);
|
||
expect((await rpc('fwa_alice', 'prompts/list', {}, session)).body?.error?.code).toBe(-32601);
|
||
});
|
||
|
||
it('end a session with DELETE', async () => {
|
||
const session = await initialize('fwa_alice');
|
||
const res = await fetch(url(), {
|
||
method: 'DELETE',
|
||
headers: { Authorization: 'Bearer fwa_alice', 'Mcp-Session-Id': session },
|
||
});
|
||
expect(res.status).toBe(204);
|
||
expect((await rpc('fwa_alice', 'tools/list', {}, session)).status).toBe(404);
|
||
});
|
||
|
||
it('send no body larger than the limit', async () => {
|
||
await start({ maxBodyBytes: 1000 });
|
||
const res = await fetch(url(), {
|
||
method: 'POST',
|
||
headers: { Authorization: 'Bearer fwa_alice', 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
jsonrpc: '2.0',
|
||
id: 1,
|
||
method: 'initialize',
|
||
params: { pad: 'x'.repeat(2000) },
|
||
}),
|
||
});
|
||
expect(res.status).toBe(413);
|
||
});
|
||
});
|
||
|
||
describe('tabs', () => {
|
||
it('get a call through to the tab of the same account and its answer back', async () => {
|
||
await onTab('alice');
|
||
const session = await initialize('fwa_alice');
|
||
const answer = await call('fwa_alice', session, 'get_view');
|
||
expect(answer.body?.result).toEqual({
|
||
content: [{ type: 'text', text: 'alice did get_view' }],
|
||
isError: false,
|
||
});
|
||
});
|
||
|
||
it('without a tab, a call says so and how to fix it', async () => {
|
||
const session = await initialize('fwa_alice');
|
||
const answer = await call('fwa_alice', session, 'get_view');
|
||
expect(toolResult(answer.body).isError).toBe(true);
|
||
expect(textOf(answer.body)).toMatch(/No Feuerwerk editor is connected.*"Agent"/);
|
||
});
|
||
|
||
it('never reach the tab of another account', async () => {
|
||
const bobs = await onTab('bob');
|
||
const calls: string[] = [];
|
||
bobs.answer = (message) => {
|
||
calls.push(message.tool ?? '');
|
||
return { content: [{ type: 'text', text: 'bob' }] };
|
||
};
|
||
const session = await initialize('fwa_alice');
|
||
const answer = await call('fwa_alice', session, 'get_view');
|
||
expect(toolResult(answer.body).isError).toBe(true);
|
||
expect(calls).toEqual([]);
|
||
});
|
||
|
||
it('are refused from another origin or none, before a ticket is asked for', async () => {
|
||
const foreign = connectTab('alice', 'https://evil.test');
|
||
const none = connectTab('alice', null);
|
||
expect(await foreign.closed).toBe(1006);
|
||
expect(await none.closed).toBe(1006);
|
||
expect(tickets.size).toBe(2);
|
||
});
|
||
|
||
it('need a ticket the API takes, once', async () => {
|
||
const tab = connectTab('alice', ORIGIN, 'shared');
|
||
await tab.next('welcome');
|
||
tickets.delete('shared');
|
||
const again = connectTab('alice', ORIGIN, 'other');
|
||
tickets.delete('other');
|
||
expect(await again.closed).toBe(CLOSE.refused);
|
||
expect(relay.stats().tabs).toEqual(['alice']);
|
||
});
|
||
|
||
it('that say no hello in time are let go', async () => {
|
||
await start({ helloTimeoutMs: 100 });
|
||
const socket = new WebSocket(url().replace('http', 'ws'), {
|
||
headers: { Origin: ORIGIN },
|
||
} as unknown as string[]);
|
||
open.push(socket);
|
||
const code = await new Promise((resolve) =>
|
||
socket.addEventListener('close', (event) => resolve(event.code)),
|
||
);
|
||
expect(code).toBe(CLOSE.helloTimeout);
|
||
});
|
||
|
||
it('one per account: the next takes over, and a call open on the first ends', async () => {
|
||
const first = await onTab('alice');
|
||
first.answer = () => null;
|
||
const session = await initialize('fwa_alice');
|
||
const pending = call('fwa_alice', session, 'get_view');
|
||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||
const second = await onTab('Alice');
|
||
expect((await first.next('replaced')).type).toBe('replaced');
|
||
expect(await first.closed).toBe(CLOSE.replaced);
|
||
expect(textOf((await pending).body)).toBe('Another editor tab took over');
|
||
expect(textOf((await call('fwa_alice', session, 'get_view')).body)).toBe('Alice did get_view');
|
||
second.socket.close();
|
||
});
|
||
|
||
it('that do not answer in time end the call with an error', async () => {
|
||
await start({ callTimeoutMs: 100 });
|
||
const tab = await onTab('alice');
|
||
tab.answer = () => null;
|
||
const session = await initialize('fwa_alice');
|
||
const answer = await call('fwa_alice', session, 'screenshot');
|
||
expect(answer.body?.result).toEqual({
|
||
content: [{ type: 'text', text: 'The editor did not answer within 0 s' }],
|
||
isError: true,
|
||
});
|
||
});
|
||
|
||
it('that ask the user something get the longer time, once', async () => {
|
||
await start({ callTimeoutMs: 100, waitTimeoutMs: 400 });
|
||
const tab = await onTab('alice');
|
||
tab.answer = (message) => {
|
||
tab.socket.send(JSON.stringify({ type: 'wait', id: message.id }));
|
||
if (message.tool === 'get_view') {
|
||
setTimeout(() => {
|
||
tab.socket.send(
|
||
JSON.stringify({ type: 'result', id: message.id, content: [{ type: 'text', text: 'yes' }] }),
|
||
);
|
||
}, 250);
|
||
} else {
|
||
tab.socket.send(JSON.stringify({ type: 'wait', id: message.id }));
|
||
}
|
||
return null;
|
||
};
|
||
const session = await initialize('fwa_alice');
|
||
expect(textOf((await call('fwa_alice', session, 'get_view')).body)).toBe('yes');
|
||
const started = Date.now();
|
||
const unanswered = await call('fwa_alice', session, 'screenshot');
|
||
const took = Date.now() - started;
|
||
expect(toolResult(unanswered.body).isError).toBe(true);
|
||
expect([took >= 350, took < 1500]).toEqual([true, true]);
|
||
});
|
||
|
||
it('pass a picture of several hundred kilobytes through, and only content the protocol knows', async () => {
|
||
const tab = await onTab('alice');
|
||
const data = Buffer.alloc(300_000, 7).toString('base64');
|
||
tab.answer = () => ({
|
||
content: [
|
||
{ type: 'image', data, mimeType: 'image/png' },
|
||
{ type: 'script', text: 'x' },
|
||
],
|
||
structuredContent: { width: 10 },
|
||
});
|
||
const session = await initialize('fwa_alice');
|
||
const answer = await call('fwa_alice', session, 'screenshot');
|
||
expect(toolResult(answer.body).content).toEqual([{ type: 'image', data, mimeType: 'image/png' }]);
|
||
expect(toolResult(answer.body).structuredContent).toEqual({ width: 10 });
|
||
});
|
||
|
||
it('learn how many agents their account has on, and an agent gone quiet counts out', async () => {
|
||
await start({ agentRecentMs: 150, pingMs: 50 });
|
||
await call('fwa_alice', await initialize('fwa_alice'), 'get_view');
|
||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||
const tab = await onTab('alice');
|
||
expect((await tab.next('agents')).count).toBe(0);
|
||
await call('fwa_alice', await initialize('fwa_alice'), 'get_view');
|
||
expect((await tab.next('agents')).count).toBe(1);
|
||
expect((await tab.next('agents')).count).toBe(0);
|
||
});
|
||
|
||
it('do not count the spare sessions a client opens and never calls a tool in', async () => {
|
||
const tab = await onTab('alice');
|
||
expect((await tab.next('agents')).count).toBe(0);
|
||
await initialize('fwa_alice');
|
||
await initialize('fwa_alice');
|
||
await call('fwa_alice', await initialize('fwa_alice'), 'get_view');
|
||
expect((await tab.next('agents')).count).toBe(1);
|
||
expect(tab.received.filter((message) => message.type === 'agents')).toEqual([]);
|
||
});
|
||
|
||
it('count out when the event stream of an agent drops, without a DELETE', async () => {
|
||
await start({ agentRecentMs: 150, pingMs: 50 });
|
||
const tab = await onTab('alice');
|
||
expect((await tab.next('agents')).count).toBe(0);
|
||
const session = await initialize('fwa_alice');
|
||
const gone = new AbortController();
|
||
const stream = await fetch(url(), {
|
||
headers: { Authorization: 'Bearer fwa_alice', 'Mcp-Session-Id': session, Accept: 'text/event-stream' },
|
||
signal: gone.signal,
|
||
});
|
||
expect(stream.status).toBe(200);
|
||
expect((await tab.next('agents')).count).toBe(1);
|
||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||
expect(tab.received.filter((message) => message.type === 'agents')).toEqual([]);
|
||
gone.abort();
|
||
expect((await tab.next('agents')).count).toBe(0);
|
||
});
|
||
|
||
it('do not count an agent that drops its event stream while its token is still being checked', async () => {
|
||
const tab = await onTab('alice');
|
||
expect((await tab.next('agents')).count).toBe(0);
|
||
const session = await initialize('fwa_alice');
|
||
verifyMs = 100;
|
||
const gone = new AbortController();
|
||
const stream = fetch(url(), {
|
||
headers: { Authorization: 'Bearer fwa_alice', 'Mcp-Session-Id': session, Accept: 'text/event-stream' },
|
||
signal: gone.signal,
|
||
}).catch(() => null);
|
||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||
gone.abort();
|
||
await stream;
|
||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||
expect(tab.received.filter((message) => message.type === 'agents')).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('the view', () => {
|
||
it('is read as a resource, and a subscribed agent hears when it changes', async () => {
|
||
const tab = await onTab('alice');
|
||
const session = await initialize('fwa_alice');
|
||
expect((await rpc('fwa_alice', 'resources/list', {}, session)).body?.result).toMatchObject({
|
||
resources: [{ uri: VIEW_URI }],
|
||
});
|
||
expect((await rpc('fwa_alice', 'resources/subscribe', { uri: VIEW_URI }, session)).body?.result).toEqual(
|
||
{},
|
||
);
|
||
|
||
const stream = await fetch(url(), {
|
||
headers: { Authorization: 'Bearer fwa_alice', 'Mcp-Session-Id': session, Accept: 'text/event-stream' },
|
||
});
|
||
expect(stream.headers.get('content-type')).toBe('text/event-stream');
|
||
const reader = stream.body!.getReader();
|
||
const heard = (async () => {
|
||
let text = '';
|
||
while (!text.includes('notifications/resources/updated')) {
|
||
const { value, done } = await reader.read();
|
||
if (done) break;
|
||
text += new TextDecoder().decode(value);
|
||
}
|
||
return text;
|
||
})();
|
||
tab.socket.send(JSON.stringify({ type: 'view', view: { tab: 'graph' }, summary: 'tab graph' }));
|
||
expect(await heard).toContain(`"params":{"uri":"${VIEW_URI}"}`);
|
||
await reader.cancel();
|
||
|
||
const read = await rpc('fwa_alice', 'resources/read', { uri: VIEW_URI }, session);
|
||
expect(JSON.parse(viewText(read.body))).toEqual({ tab: 'graph' });
|
||
});
|
||
|
||
it('read without a tab says it is not connected', async () => {
|
||
const session = await initialize('fwa_alice');
|
||
const read = await rpc('fwa_alice', 'resources/read', { uri: VIEW_URI }, session);
|
||
expect(JSON.parse(viewText(read.body))).toMatchObject({ connected: false });
|
||
});
|
||
|
||
it('changed since the agent last looked is told with the next answer, once', async () => {
|
||
const tab = await onTab('alice');
|
||
const session = await initialize('fwa_alice');
|
||
tab.socket.send(JSON.stringify({ type: 'view', view: { tab: 'scene' }, summary: 'tab scene' }));
|
||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||
await call('fwa_alice', session, 'get_view');
|
||
const same = await call('fwa_alice', session, 'screenshot');
|
||
tab.socket.send(JSON.stringify({ type: 'view', view: { tab: 'graph' }, summary: 'tab graph' }));
|
||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||
const changed = await call('fwa_alice', session, 'screenshot');
|
||
const after = await call('fwa_alice', session, 'screenshot');
|
||
expect(toolResult(same.body).content).toHaveLength(1);
|
||
expect(textOf(changed.body, 1)).toBe("The user's view has changed since you last looked: tab graph");
|
||
expect(toolResult(after.body).content).toHaveLength(1);
|
||
});
|
||
});
|
||
|
||
describe('in front of a plain server', () => {
|
||
let backend: Server;
|
||
afterEach(() => new Promise<void>((resolve) => backend.close(() => resolve())));
|
||
|
||
it('passes everything off its own path on', async () => {
|
||
backend = createServer((req, res) =>
|
||
res.end(`backend ${req.method} ${req.url} ${req.headers.range ?? ''}`),
|
||
);
|
||
await new Promise<void>((resolve) => backend.listen(0, '127.0.0.1', () => resolve()));
|
||
const port = (backend.address() as { port: number }).port;
|
||
await start({ backend: `http://127.0.0.1:${port}` });
|
||
const page = await fetch(`http://127.0.0.1:${relay.port}/feuerwerk/data/x.mp4?y=1`, {
|
||
headers: { Range: 'bytes=0-9' },
|
||
});
|
||
expect(await page.text()).toBe('backend GET /feuerwerk/data/x.mp4?y=1 bytes=0-9');
|
||
expect((await fetch(`http://127.0.0.1:${relay.port}/feuerwerk/mcp`, { method: 'POST' })).status).toBe(
|
||
401,
|
||
);
|
||
});
|
||
});
|