import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { afterEach, beforeEach, describe, it, mock } from 'node:test';
import WebSocket from 'ws';
import { HttpClient } from './http-client.js';
import { WebSocketClient } from './websocket-client.js';
class FakeSocket extends EventEmitter {
    readyState = WebSocket.CONNECTING;
    send() { }
    close() {
        this.readyState = WebSocket.CLOSED;
    }
    terminate() {
        this.readyState = WebSocket.CLOSED;
    }
}
describe('HttpClient', () => {
    const originalFetch = global.fetch;
    let fetchCalls = [];
    beforeEach(() => {
        fetchCalls = [];
        global.fetch = async (url, options) => {
            fetchCalls.push([url.toString(), options]);
            return {
                ok: true,
                status: 200,
                statusText: 'OK',
                json: async () => ({ result: 'success' }),
            };
        };
    });
    afterEach(() => {
        global.fetch = originalFetch;
        mock.restoreAll();
    });
    describe('constructor and URL resolution', () => {
        it('uses explicit mcpHttpUrl when provided', () => {
            const config = {
                serverUrl: 'ws://localhost:8080/ws/agent',
                heartbeatInterval: 15000,
                reconnectInterval: 200,
                maxReconnectAttempts: -1,
                mcpHttpUrl: 'http://custom:3000/mcp/call',
            };
            const client = new HttpClient(config);
            assert.equal(client.getMcpHttpUrl(), 'http://custom:3000/mcp/call');
        });
        it('appends /mcp/call to mcpHttpUrl when not present', () => {
            const config = {
                serverUrl: 'ws://localhost:8080/ws/agent',
                heartbeatInterval: 15000,
                reconnectInterval: 200,
                maxReconnectAttempts: -1,
                mcpHttpUrl: 'http://custom:3000',
            };
            const client = new HttpClient(config);
            assert.equal(client.getMcpHttpUrl(), 'http://custom:3000/mcp/call');
        });
        it('appends /mcp/call to mcpHttpUrl with trailing slash', () => {
            const config = {
                serverUrl: 'ws://localhost:8080/ws/agent',
                heartbeatInterval: 15000,
                reconnectInterval: 200,
                maxReconnectAttempts: -1,
                mcpHttpUrl: 'http://custom:3000/',
            };
            const client = new HttpClient(config);
            assert.equal(client.getMcpHttpUrl(), 'http://custom:3000/mcp/call');
        });
        it('derives HTTP URL from WebSocket URL (ws://)', () => {
            const config = {
                serverUrl: 'ws://localhost:8080/ws/agent',
                heartbeatInterval: 15000,
                reconnectInterval: 200,
                maxReconnectAttempts: -1,
            };
            const client = new HttpClient(config);
            assert.equal(client.getMcpHttpUrl(), 'http://127.0.0.1:8080/mcp/call');
        });
        it('derives HTTP URL from WebSocket URL (wss://)', () => {
            const config = {
                serverUrl: 'wss://secure.example.com/ws/agent',
                heartbeatInterval: 15000,
                reconnectInterval: 200,
                maxReconnectAttempts: -1,
            };
            const client = new HttpClient(config);
            assert.equal(client.getMcpHttpUrl(), 'https://secure.example.com/mcp/call');
        });
        it('handles empty mcpHttpUrl by deriving from serverUrl', () => {
            const config = {
                serverUrl: 'ws://localhost:8080/ws/agent',
                heartbeatInterval: 15000,
                reconnectInterval: 200,
                maxReconnectAttempts: -1,
                mcpHttpUrl: '',
            };
            const client = new HttpClient(config);
            assert.equal(client.getMcpHttpUrl(), 'http://127.0.0.1:8080/mcp/call');
        });
        it('handles whitespace-only mcpHttpUrl', () => {
            const config = {
                serverUrl: 'ws://localhost:8080/ws/agent',
                heartbeatInterval: 15000,
                reconnectInterval: 200,
                maxReconnectAttempts: -1,
                mcpHttpUrl: '   ',
            };
            const client = new HttpClient(config);
            assert.equal(client.getMcpHttpUrl(), 'http://127.0.0.1:8080/mcp/call');
        });
        it('repairs duplicated ports from configured mcpHttpUrl', () => {
            const config = {
                serverUrl: 'ws://localhost:8080/ws/agent',
                heartbeatInterval: 15000,
                reconnectInterval: 200,
                maxReconnectAttempts: -1,
                mcpHttpUrl: 'http://127.0.0.1:8080:8080/mcp/call',
            };
            const client = new HttpClient(config);
            assert.equal(client.getMcpHttpUrl(), 'http://127.0.0.1:8080/mcp/call');
        });
        it('repairs duplicated ports from serverUrl', () => {
            const config = {
                serverUrl: 'ws://127.0.0.1:8080:8080/ws/agent',
                heartbeatInterval: 15000,
                reconnectInterval: 200,
                maxReconnectAttempts: -1,
            };
            const client = new HttpClient(config);
            assert.equal(client.getMcpHttpUrl(), 'http://127.0.0.1:8080/mcp/call');
        });
        it('preserves configured MCP HTTP subpaths', () => {
            const config = {
                serverUrl: 'ws://localhost:8080/ws/agent',
                heartbeatInterval: 15000,
                reconnectInterval: 200,
                maxReconnectAttempts: -1,
                mcpHttpUrl: 'http://custom:3000/api',
            };
            const client = new HttpClient(config);
            assert.equal(client.getMcpHttpUrl(), 'http://custom:3000/api/mcp/call');
        });
    });
    describe('callTool', () => {
        it('makes POST request with runtime token headers', async () => {
            const config = {
                serverUrl: 'ws://localhost:8080/ws/agent',
                heartbeatInterval: 15000,
                reconnectInterval: 200,
                maxReconnectAttempts: -1,
                runtimeAccessToken: 'runtime-token',
            };
            const client = new HttpClient(config);
            await client.callTool('test-tool', { arg1: 'value1' });
            assert.equal(fetchCalls.length, 1);
            const [url, options] = fetchCalls[0];
            assert.equal(url, 'http://127.0.0.1:8080/mcp/call');
            assert.equal(options.method, 'POST');
            assert.equal(options.headers['Content-Type'], 'application/json');
            assert.equal(options.headers['X-Runtime-Token'], 'runtime-token');
            assert.equal(options.headers.Authorization, 'Bearer runtime-token');
            assert.equal(options.body, JSON.stringify({ tool: 'test-tool', arguments: { arg1: 'value1' } }));
        });
        it('makes POST request without runtime token when not configured', async () => {
            const config = {
                serverUrl: 'ws://localhost:8080/ws/agent',
                heartbeatInterval: 15000,
                reconnectInterval: 200,
                maxReconnectAttempts: -1,
            };
            const client = new HttpClient(config);
            await client.callTool('test-tool', {});
            const [, options] = fetchCalls[0];
            assert.equal(options.headers['X-Runtime-Token'], undefined);
            assert.equal(options.headers.Authorization, undefined);
        });
        it('sets an AbortSignal for poll_message long-poll requests', async () => {
            const config = {
                serverUrl: 'ws://localhost:8080/ws/agent',
                heartbeatInterval: 15000,
                reconnectInterval: 200,
                maxReconnectAttempts: -1,
                runtimeAccessToken: 'runtime-token',
            };
            const client = new HttpClient(config);
            await client.callTool('poll_message', { agentId: 'agent-1' });
            const [, options] = fetchCalls[0];
            assert.ok(options.signal instanceof AbortSignal);
        });
        it('keeps an AbortSignal for non-blocking MCP tools', async () => {
            const config = {
                serverUrl: 'ws://localhost:8080/ws/agent',
                heartbeatInterval: 15000,
                reconnectInterval: 200,
                maxReconnectAttempts: -1,
                runtimeAccessToken: 'runtime-token',
            };
            const client = new HttpClient(config);
            await client.callTool('get_message', { agentId: 'agent-1' });
            const [, options] = fetchCalls[0];
            assert.ok(options.signal instanceof AbortSignal);
        });
        it('returns parsed result on success', async () => {
            const config = {
                serverUrl: 'ws://localhost:8080/ws/agent',
                heartbeatInterval: 15000,
                reconnectInterval: 200,
                maxReconnectAttempts: -1,
            };
            const client = new HttpClient(config);
            global.fetch = async () => ({
                ok: true,
                json: async () => ({ data: 'test-data', count: 42 }),
            });
            const result = await client.callTool('test-tool', {});
            assert.deepEqual(result, { data: 'test-data', count: 42 });
        });
        it('throws error when response is not ok', async () => {
            const config = {
                serverUrl: 'ws://localhost:8080/ws/agent',
                heartbeatInterval: 15000,
                reconnectInterval: 200,
                maxReconnectAttempts: -1,
            };
            const client = new HttpClient(config);
            global.fetch = async () => ({
                ok: false,
                status: 500,
                statusText: 'Internal Server Error',
            });
            await assert.rejects(() => client.callTool('test-tool', {}), /MCP tool test-tool call failed: 500 Internal Server Error/);
        });
        it('includes response body when response is not ok', async () => {
            const config = {
                serverUrl: 'ws://localhost:8080/ws/agent',
                heartbeatInterval: 15000,
                reconnectInterval: 200,
                maxReconnectAttempts: -1,
            };
            const client = new HttpClient(config);
            global.fetch = async () => ({
                ok: false,
                status: 503,
                statusText: 'Service Unavailable',
                text: async () => '{"error":"Async request timed out"}',
            });
            await assert.rejects(() => client.callTool('poll_message', { agentId: 'agent-1' }), /MCP tool poll_message call failed: 503 Service Unavailable - {"error":"Async request timed out"}/);
        });
        it('throws a clear tool error when result contains error field', async () => {
            const config = {
                serverUrl: 'ws://localhost:8080/ws/agent',
                heartbeatInterval: 15000,
                reconnectInterval: 200,
                maxReconnectAttempts: -1,
            };
            const client = new HttpClient(config);
            global.fetch = async () => ({
                ok: true,
                json: async () => ({ error: 'Tool execution failed' }),
            });
            await assert.rejects(() => client.callTool('test-tool', {}), /MCP tool test-tool failed: Tool execution failed/);
        });
        it('handles 401 unauthorized response', async () => {
            const config = {
                serverUrl: 'ws://localhost:8080/ws/agent',
                heartbeatInterval: 15000,
                reconnectInterval: 200,
                maxReconnectAttempts: -1,
            };
            const client = new HttpClient(config);
            global.fetch = async () => ({
                ok: false,
                status: 401,
                statusText: 'Unauthorized',
            });
            await assert.rejects(() => client.callTool('test-tool', {}), /MCP tool test-tool call failed: 401 Unauthorized/);
        });
        it('force-refreshes runtime token and retries once on 401', async () => {
            const config = {
                serverUrl: 'ws://localhost:8080/ws/agent',
                heartbeatInterval: 15000,
                reconnectInterval: 200,
                maxReconnectAttempts: -1,
                userId: 'user-1',
                runtimeAccessToken: 'stale-token',
                runtimeBridgeBaseUrl: 'http://127.0.0.1:18081',
            };
            const client = new HttpClient(config);
            global.fetch = async (url, options) => {
                fetchCalls.push([url.toString(), options]);
                if (url.toString() === 'http://127.0.0.1:8080/mcp/call' && fetchCalls.length === 1) {
                    return {
                        ok: false,
                        status: 401,
                        statusText: 'Unauthorized',
                    };
                }
                if (url.toString() === 'http://127.0.0.1:18081/runtime/binding/request-token') {
                    return {
                        ok: true,
                        status: 200,
                        statusText: 'OK',
                        json: async () => ({ ok: true, runtimeAccessToken: 'fresh-token' }),
                    };
                }
                return {
                    ok: true,
                    status: 200,
                    statusText: 'OK',
                    json: async () => ({ data: 'retried' }),
                };
            };
            const result = await client.callTool('my_task', { agentId: 'agent-1' });
            assert.deepEqual(result, { data: 'retried' });
            assert.equal(fetchCalls.length, 3);
            assert.equal(fetchCalls[0][1].headers['X-Runtime-Token'], 'stale-token');
            assert.equal(fetchCalls[1][0], 'http://127.0.0.1:18081/runtime/binding/request-token');
            assert.equal(fetchCalls[1][1].body, JSON.stringify({
                userId: 'user-1',
                serverUrl: 'ws://localhost:8080/ws/agent',
                forceRefresh: true,
                rejectedRuntimeAccessToken: 'stale-token',
            }));
            assert.equal(fetchCalls[2][1].headers['X-Runtime-Token'], 'fresh-token');
            assert.equal(config.runtimeAccessToken, 'fresh-token');
        });
        it('shares one forced runtime token refresh across concurrent 401 responses', async () => {
            const config = {
                serverUrl: 'ws://localhost:8080/ws/agent',
                heartbeatInterval: 15000,
                reconnectInterval: 200,
                maxReconnectAttempts: -1,
                userId: 'user-1',
                runtimeAccessToken: 'stale-token',
                runtimeBridgeBaseUrl: 'http://127.0.0.1:18081',
            };
            const client = new HttpClient(config);
            const staleTokenCalls = new Set();
            global.fetch = async (url, options) => {
                fetchCalls.push([url.toString(), options]);
                if (url.toString() === 'http://127.0.0.1:18081/runtime/binding/request-token') {
                    await new Promise((resolve) => setTimeout(resolve, 10));
                    return {
                        ok: true,
                        status: 200,
                        statusText: 'OK',
                        json: async () => ({ ok: true, runtimeAccessToken: 'fresh-token' }),
                    };
                }
                const body = JSON.parse(String(options?.body || '{}'));
                const token = options.headers['X-Runtime-Token'];
                if (token === 'stale-token') {
                    staleTokenCalls.add(body.tool || '');
                    return {
                        ok: false,
                        status: 401,
                        statusText: 'Unauthorized',
                    };
                }
                return {
                    ok: true,
                    status: 200,
                    statusText: 'OK',
                    json: async () => ({ tool: body.tool, token }),
                };
            };
            const [first, second] = await Promise.all([
                client.callTool('get_memory_stats', { agentId: 'agent-1' }),
                client.callTool('discover_group_rooms', { agentId: 'agent-1' }),
            ]);
            assert.deepEqual(first, { tool: 'get_memory_stats', token: 'fresh-token' });
            assert.deepEqual(second, { tool: 'discover_group_rooms', token: 'fresh-token' });
            assert.deepEqual(staleTokenCalls, new Set(['get_memory_stats', 'discover_group_rooms']));
            assert.equal(fetchCalls.filter(([url]) => url === 'http://127.0.0.1:18081/runtime/binding/request-token').length, 1);
            assert.equal(config.runtimeAccessToken, 'fresh-token');
        });
        it('shares one initial runtime token request across concurrent calls without a token', async () => {
            const config = {
                serverUrl: 'ws://localhost:8080/ws/agent',
                heartbeatInterval: 15000,
                reconnectInterval: 200,
                maxReconnectAttempts: -1,
                userId: 'user-1',
                runtimeBridgeBaseUrl: 'http://127.0.0.1:18081',
            };
            const client = new HttpClient(config);
            global.fetch = async (url, options) => {
                fetchCalls.push([url.toString(), options]);
                if (url.toString() === 'http://127.0.0.1:18081/runtime/binding/request-token') {
                    await new Promise((resolve) => setTimeout(resolve, 10));
                    return {
                        ok: true,
                        status: 200,
                        statusText: 'OK',
                        json: async () => ({ ok: true, runtimeAccessToken: 'startup-token' }),
                    };
                }
                const body = JSON.parse(String(options?.body || '{}'));
                return {
                    ok: true,
                    status: 200,
                    statusText: 'OK',
                    json: async () => ({ tool: body.tool, token: options.headers['X-Runtime-Token'] }),
                };
            };
            const [first, second] = await Promise.all([
                client.callTool('get_memory_stats', { agentId: 'agent-1' }),
                client.callTool('discover_group_rooms', { agentId: 'agent-1' }),
            ]);
            assert.deepEqual(first, { tool: 'get_memory_stats', token: 'startup-token' });
            assert.deepEqual(second, { tool: 'discover_group_rooms', token: 'startup-token' });
            assert.equal(fetchCalls.filter(([url]) => url === 'http://127.0.0.1:18081/runtime/binding/request-token').length, 1);
            assert.equal(fetchCalls[0][1].body, JSON.stringify({
                userId: 'user-1',
                serverUrl: 'ws://localhost:8080/ws/agent',
                forceRefresh: false,
            }));
            assert.equal(config.runtimeAccessToken, 'startup-token');
        });
        it('clears failed runtime token refresh so later calls can retry', async () => {
            const config = {
                serverUrl: 'ws://localhost:8080/ws/agent',
                heartbeatInterval: 15000,
                reconnectInterval: 200,
                maxReconnectAttempts: -1,
                userId: 'user-1',
                runtimeBridgeBaseUrl: 'http://127.0.0.1:18081',
            };
            const client = new HttpClient(config);
            let tokenRequestCount = 0;
            global.fetch = async (url, options) => {
                fetchCalls.push([url.toString(), options]);
                if (url.toString() === 'http://127.0.0.1:18081/runtime/binding/request-token') {
                    tokenRequestCount += 1;
                    if (tokenRequestCount === 1) {
                        return {
                            ok: false,
                            status: 503,
                            statusText: 'Service Unavailable',
                            text: async () => 'bridge unavailable',
                        };
                    }
                    return {
                        ok: true,
                        status: 200,
                        statusText: 'OK',
                        json: async () => ({ ok: true, runtimeAccessToken: 'recovered-token' }),
                    };
                }
                return {
                    ok: true,
                    status: 200,
                    statusText: 'OK',
                    json: async () => ({ token: options.headers['X-Runtime-Token'] }),
                };
            };
            await assert.rejects(() => client.callTool('get_memory_stats', { agentId: 'agent-1' }), /Failed to request runtime token from runtime-bridge: 503 Service Unavailable - bridge unavailable/);
            const result = await client.callTool('get_memory_stats', { agentId: 'agent-1' });
            assert.deepEqual(result, { token: 'recovered-token' });
            assert.equal(tokenRequestCount, 2);
            assert.equal(config.runtimeAccessToken, 'recovered-token');
        });
        it('shares one runtime token refresh between HTTP tools and websocket recovery', async () => {
            const config = {
                serverUrl: 'ws://localhost:8080/ws/agent',
                heartbeatInterval: 1000,
                heartbeatTimeout: 3000,
                reconnectInterval: 10,
                maxReconnectAttempts: 2,
                userId: 'user-1',
                runtimeAccessToken: 'stale-token',
                runtimeBridgeBaseUrl: 'http://127.0.0.1:18081',
            };
            const httpClient = new HttpClient(config);
            let tokenRequestCount = 0;
            let websocketAttempt = 0;
            const websocketUrls = [];
            let releaseRefresh;
            const refreshGate = new Promise((resolve) => {
                releaseRefresh = resolve;
            });
            global.fetch = async (url, options) => {
                fetchCalls.push([url.toString(), options]);
                if (url.toString() === 'http://127.0.0.1:18081/runtime/binding/request-token') {
                    tokenRequestCount += 1;
                    await refreshGate;
                    return {
                        ok: true,
                        status: 200,
                        statusText: 'OK',
                        json: async () => ({ ok: true, runtimeAccessToken: 'shared-fresh-token' }),
                    };
                }
                const body = JSON.parse(String(options?.body || '{}'));
                const token = options.headers['X-Runtime-Token'];
                if (token === 'stale-token') {
                    return {
                        ok: false,
                        status: 401,
                        statusText: 'Unauthorized',
                    };
                }
                return {
                    ok: true,
                    status: 200,
                    statusText: 'OK',
                    json: async () => ({ tool: body.tool, token }),
                };
            };
            const wsClient = new WebSocketClient(config, (url) => {
                websocketUrls.push(url);
                websocketAttempt += 1;
                const socket = new FakeSocket();
                setImmediate(() => {
                    socket.readyState = WebSocket.OPEN;
                    socket.emit('open');
                    if (websocketAttempt === 1) {
                        socket.readyState = WebSocket.CLOSED;
                        socket.emit('close', 1008, Buffer.from('policy violation'));
                    }
                });
                return socket;
            });
            try {
                wsClient.on('error', () => {
                    // suppress unhandled error event in test environment
                });
                const websocketConnected = wsClient.connect('agent-1');
                const httpCall = httpClient.callTool('get_memory_stats', { agentId: 'agent-1' });
                await new Promise((resolve, reject) => {
                    const timer = setTimeout(() => reject(new Error('expected both refresh waiters')), 500);
                    const interval = setInterval(() => {
                        if (tokenRequestCount === 1 && config.runtimeAccessTokenRefreshPromise) {
                            clearInterval(interval);
                            clearTimeout(timer);
                            resolve();
                        }
                    }, 5);
                });
                const websocketReconnected = new Promise((resolve, reject) => {
                    const timer = setTimeout(() => reject(new Error('expected websocket reconnect')), 500);
                    wsClient.on('connected', () => {
                        if (websocketAttempt >= 2) {
                            clearTimeout(timer);
                            resolve();
                        }
                    });
                });
                releaseRefresh?.();
                const result = await httpCall;
                await websocketConnected;
                await websocketReconnected;
                assert.deepEqual(result, { tool: 'get_memory_stats', token: 'shared-fresh-token' });
                assert.equal(tokenRequestCount, 1);
                assert.equal(config.runtimeAccessToken, 'shared-fresh-token');
                assert.equal(new URL(websocketUrls[1] || '').searchParams.get('runtimeAccessToken'), 'shared-fresh-token');
            }
            finally {
                releaseRefresh?.();
                wsClient.disconnect();
            }
        });
        it('shares one forced refresh among multiple waiters after an initial non-forced refresh', async () => {
            const config = {
                serverUrl: 'ws://localhost:8080/ws/agent',
                heartbeatInterval: 15000,
                reconnectInterval: 200,
                maxReconnectAttempts: -1,
                userId: 'user-1',
                runtimeBridgeBaseUrl: 'http://127.0.0.1:18081',
            };
            const initialClient = new HttpClient(config);
            const forceClient = new HttpClient(config);
            let tokenRequestCount = 0;
            let forceRequestCount = 0;
            let releaseInitialRefresh;
            const initialRefreshGate = new Promise((resolve) => {
                releaseInitialRefresh = resolve;
            });
            global.fetch = async (url, options) => {
                fetchCalls.push([url.toString(), options]);
                if (url.toString() === 'http://127.0.0.1:18081/runtime/binding/request-token') {
                    tokenRequestCount += 1;
                    const body = JSON.parse(String(options?.body || '{}'));
                    if (body.forceRefresh) {
                        forceRequestCount += 1;
                        await new Promise((resolve) => setTimeout(resolve, 10));
                        return {
                            ok: true,
                            status: 200,
                            statusText: 'OK',
                            json: async () => ({ ok: true, runtimeAccessToken: 'forced-token' }),
                        };
                    }
                    await initialRefreshGate;
                    return {
                        ok: true,
                        status: 200,
                        statusText: 'OK',
                        json: async () => ({ ok: true, runtimeAccessToken: 'initial-token' }),
                    };
                }
                const body = JSON.parse(String(options?.body || '{}'));
                const token = options.headers['X-Runtime-Token'];
                if (token === 'initial-token') {
                    return {
                        ok: false,
                        status: 401,
                        statusText: 'Unauthorized',
                    };
                }
                return {
                    ok: true,
                    status: 200,
                    statusText: 'OK',
                    json: async () => ({ tool: body.tool, token }),
                };
            };
            try {
                const initialCall = initialClient.callTool('get_profile', { agentId: 'agent-1' });
                await new Promise((resolve, reject) => {
                    const timer = setTimeout(() => reject(new Error('expected initial refresh to start')), 500);
                    const interval = setInterval(() => {
                        if (tokenRequestCount === 1 && config.runtimeAccessTokenRefreshPromise) {
                            clearInterval(interval);
                            clearTimeout(timer);
                            resolve();
                        }
                    }, 5);
                });
                const firstForceCall = forceClient.callTool('get_memory_stats', { agentId: 'agent-1' });
                const secondForceCall = forceClient.callTool('discover_group_rooms', { agentId: 'agent-1' });
                releaseInitialRefresh?.();
                const [initialResult, firstForceResult, secondForceResult] = await Promise.all([
                    initialCall,
                    firstForceCall,
                    secondForceCall,
                ]);
                assert.deepEqual(initialResult, { tool: 'get_profile', token: 'forced-token' });
                assert.deepEqual(firstForceResult, { tool: 'get_memory_stats', token: 'forced-token' });
                assert.deepEqual(secondForceResult, { tool: 'discover_group_rooms', token: 'forced-token' });
                assert.equal(tokenRequestCount, 2);
                assert.equal(forceRequestCount, 1);
                assert.equal(config.runtimeAccessToken, 'forced-token');
            }
            finally {
                releaseInitialRefresh?.();
            }
        });
        it('does not retry 401 more than once after forced token refresh', async () => {
            const config = {
                serverUrl: 'ws://localhost:8080/ws/agent',
                heartbeatInterval: 15000,
                reconnectInterval: 200,
                maxReconnectAttempts: -1,
                runtimeAccessToken: 'stale-token',
                runtimeBridgeBaseUrl: 'http://127.0.0.1:18081',
            };
            const client = new HttpClient(config);
            global.fetch = async (url, options) => {
                fetchCalls.push([url.toString(), options]);
                if (url.toString() === 'http://127.0.0.1:18081/runtime/binding/request-token') {
                    return {
                        ok: true,
                        status: 200,
                        statusText: 'OK',
                        json: async () => ({ ok: true, runtimeAccessToken: 'fresh-token' }),
                    };
                }
                return {
                    ok: false,
                    status: 401,
                    statusText: 'Unauthorized',
                };
            };
            await assert.rejects(() => client.callTool('my_task', { agentId: 'agent-1' }), /MCP tool my_task call failed: 401 Unauthorized/);
            assert.equal(fetchCalls.length, 3);
            assert.equal(fetchCalls.filter(([url]) => url === 'http://127.0.0.1:8080/mcp/call').length, 2);
            assert.equal(fetchCalls.filter(([url]) => url === 'http://127.0.0.1:18081/runtime/binding/request-token').length, 1);
        });
        it('does not repeatedly force-refresh the same rejected runtime token across calls', async () => {
            const config = {
                serverUrl: 'ws://localhost:8080/ws/agent',
                heartbeatInterval: 15000,
                reconnectInterval: 200,
                maxReconnectAttempts: -1,
                userId: 'user-1',
                runtimeAccessToken: 'stale-token',
                runtimeBridgeBaseUrl: 'http://127.0.0.1:18081',
            };
            const client = new HttpClient(config);
            global.fetch = async (url, options) => {
                fetchCalls.push([url.toString(), options]);
                if (url.toString() === 'http://127.0.0.1:18081/runtime/binding/request-token') {
                    return {
                        ok: true,
                        status: 200,
                        statusText: 'OK',
                        json: async () => ({ ok: true, runtimeAccessToken: 'fresh-but-rejected-token' }),
                    };
                }
                return {
                    ok: false,
                    status: 401,
                    statusText: 'Unauthorized',
                };
            };
            await assert.rejects(() => client.callTool('discover_group_rooms', { agentId: 'agent-1' }), /MCP tool discover_group_rooms call failed: 401 Unauthorized/);
            await assert.rejects(() => client.callTool('poll_message', { agentId: 'agent-1' }), /MCP tool poll_message call failed: 401 Unauthorized/);
            assert.equal(config.runtimeAccessToken, 'fresh-but-rejected-token');
            assert.equal(config.lastForceRefreshAttemptedForToken, 'fresh-but-rejected-token');
            assert.equal(fetchCalls.filter(([url]) => url === 'http://127.0.0.1:18081/runtime/binding/request-token').length, 1);
            assert.equal(fetchCalls.filter(([url]) => url === 'http://127.0.0.1:8080/mcp/call').length, 3);
        });
        it('does not force-refresh again while the fresh runtime token is being validated', async () => {
            const config = {
                serverUrl: 'ws://localhost:8080/ws/agent',
                heartbeatInterval: 15000,
                reconnectInterval: 200,
                maxReconnectAttempts: -1,
                userId: 'user-1',
                runtimeAccessToken: 'stale-token',
                runtimeBridgeBaseUrl: 'http://127.0.0.1:18081',
            };
            const client = new HttpClient(config);
            let resolveFreshTokenValidationStarted = () => { };
            let releaseFreshTokenValidation = () => { };
            const freshTokenValidationStarted = new Promise((resolve) => {
                resolveFreshTokenValidationStarted = resolve;
            });
            const freshTokenValidationReleased = new Promise((resolve) => {
                releaseFreshTokenValidation = resolve;
            });
            let freshTokenMcpCallCount = 0;
            global.fetch = async (url, options) => {
                fetchCalls.push([url.toString(), options]);
                if (url.toString() === 'http://127.0.0.1:18081/runtime/binding/request-token') {
                    return {
                        ok: true,
                        status: 200,
                        statusText: 'OK',
                        json: async () => ({ ok: true, runtimeAccessToken: 'fresh-but-rejected-token' }),
                    };
                }
                const requestHeaders = (options?.headers ?? {});
                if (requestHeaders.Authorization === 'Bearer fresh-but-rejected-token') {
                    freshTokenMcpCallCount += 1;
                    if (freshTokenMcpCallCount === 1) {
                        resolveFreshTokenValidationStarted();
                        await freshTokenValidationReleased;
                    }
                }
                return {
                    ok: false,
                    status: 401,
                    statusText: 'Unauthorized',
                };
            };
            const firstCall = client.callTool('discover_group_rooms', { agentId: 'agent-1' });
            await freshTokenValidationStarted;
            await assert.rejects(() => client.callTool('poll_message', { agentId: 'agent-1' }), /MCP tool poll_message call failed: 401 Unauthorized/);
            releaseFreshTokenValidation();
            await assert.rejects(() => firstCall, /MCP tool discover_group_rooms call failed: 401 Unauthorized/);
            assert.equal(config.runtimeAccessToken, 'fresh-but-rejected-token');
            assert.equal(config.lastForceRefreshAttemptedForToken, 'fresh-but-rejected-token');
            assert.equal(fetchCalls.filter(([url]) => url === 'http://127.0.0.1:18081/runtime/binding/request-token').length, 1);
            assert.equal(fetchCalls.filter(([url]) => url === 'http://127.0.0.1:8080/mcp/call').length, 3);
        });
        it('handles 404 not found response', async () => {
            const config = {
                serverUrl: 'ws://localhost:8080/ws/agent',
                heartbeatInterval: 15000,
                reconnectInterval: 200,
                maxReconnectAttempts: -1,
            };
            const client = new HttpClient(config);
            global.fetch = async () => ({
                ok: false,
                status: 404,
                statusText: 'Not Found',
            });
            await assert.rejects(() => client.callTool('unknown-tool', {}), /MCP tool unknown-tool call failed: 404 Not Found/);
        });
        it('handles network errors', async () => {
            const config = {
                serverUrl: 'ws://localhost:8080/ws/agent',
                heartbeatInterval: 15000,
                reconnectInterval: 200,
                maxReconnectAttempts: -1,
            };
            const client = new HttpClient(config);
            global.fetch = async () => {
                throw new Error('Network error');
            };
            await assert.rejects(() => client.callTool('test-tool', {}), /Network error/);
        });
        it('aborts MCP tool requests after configured timeout', async () => {
            const config = {
                serverUrl: 'ws://localhost:8080/ws/agent',
                heartbeatInterval: 15000,
                reconnectInterval: 200,
                maxReconnectAttempts: -1,
                mcpHttpRequestTimeoutMs: 10,
            };
            const client = new HttpClient(config);
            global.fetch = async (url, options) => {
                fetchCalls.push([url.toString(), options]);
                return await new Promise((_resolve, reject) => {
                    options?.signal?.addEventListener('abort', () => reject(new Error('aborted by signal')), { once: true });
                });
            };
            await assert.rejects(() => client.callTool('test-tool', {}), /MCP tool test-tool timed out after 10ms/);
            assert.equal(fetchCalls.length, 1);
            assert.ok(fetchCalls[0][1].signal instanceof AbortSignal);
        });
        it('aborts poll_message requests after configured timeout', async () => {
            const config = {
                serverUrl: 'ws://localhost:8080/ws/agent',
                heartbeatInterval: 15000,
                reconnectInterval: 200,
                maxReconnectAttempts: -1,
                runtimeAccessToken: 'runtime-token',
                mcpHttpRequestTimeoutMs: 10,
            };
            const client = new HttpClient(config);
            global.fetch = async (url, options) => {
                fetchCalls.push([url.toString(), options]);
                return await new Promise((_resolve, reject) => {
                    options?.signal?.addEventListener('abort', () => reject(new Error('aborted by signal')), { once: true });
                });
            };
            await assert.rejects(() => client.callTool('poll_message', { agentId: 'agent-1' }), /MCP tool poll_message timed out after 10ms/);
            assert.equal(fetchCalls.length, 1);
            assert.ok(fetchCalls[0][1].signal instanceof AbortSignal);
        });
    });
});
//# sourceMappingURL=http-client.test.js.map