import assert from 'node:assert/strict';
import { afterEach, beforeEach, describe, it, mock } from 'node:test';
import { HttpClient } from './http-client.js';
describe('HttpClient', { concurrency: false }, () => {
    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', { concurrency: false }, () => {
        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,
                mcpRetryMaxAccumulatedMs: 0,
            };
            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,
                mcpRetryMaxAccumulatedMs: 0,
            };
            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/refresh-token') {
                    return {
                        ok: true,
                        status: 200,
                        statusText: 'OK',
                        json: async () => ({ 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/refresh-token');
            assert.equal(fetchCalls[1][1].body, JSON.stringify({
                agentId: 'agent-1',
                currentToken: 'stale-token',
            }));
            assert.equal(fetchCalls[2][1].headers['X-Runtime-Token'], 'fresh-token');
            assert.equal(config.runtimeAccessToken, 'fresh-token');
        });
        it('shares one bridge 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/refresh-token') {
                    await new Promise((resolve) => setTimeout(resolve, 10));
                    return {
                        ok: true,
                        status: 200,
                        statusText: 'OK',
                        json: async () => ({ 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/refresh-token').length, 1);
            assert.equal(config.runtimeAccessToken, 'fresh-token');
        });
        it('throws when no runtimeAccessToken and no bridge for startup', 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 () => ({
                ok: true,
                status: 200,
                statusText: 'OK',
                json: async () => ({ result: 'success' }),
            });
            await assert.rejects(() => client.callTool('get_memory_stats', { agentId: 'agent-1' }), /runtimeAccessToken is required; MCP nodes must claim a session token from launch binding/);
        });
        it('throws original 401 when bridge refresh fails', 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/refresh-token') {
                    return {
                        ok: false,
                        status: 503,
                        statusText: 'Service Unavailable',
                    };
                }
                return {
                    ok: false,
                    status: 401,
                    statusText: 'Unauthorized',
                };
            };
            await assert.rejects(() => client.callTool('get_memory_stats', { agentId: 'agent-1' }), /MCP tool get_memory_stats call failed: 401 Unauthorized/);
            // Can retry again after failure
            global.fetch = async (url, options) => {
                fetchCalls.push([url.toString(), options]);
                if (url.toString() === 'http://127.0.0.1:18081/runtime/binding/refresh-token') {
                    return {
                        ok: true,
                        status: 200,
                        statusText: 'OK',
                        json: async () => ({ runtimeAccessToken: 'recovered-token' }),
                    };
                }
                const token = options.headers['X-Runtime-Token'];
                if (token === 'recovered-token') {
                    return {
                        ok: true,
                        status: 200,
                        statusText: 'OK',
                        json: async () => ({ token }),
                    };
                }
                return {
                    ok: false,
                    status: 401,
                    statusText: 'Unauthorized',
                };
            };
            const result = await client.callTool('get_memory_stats', { agentId: 'agent-1' });
            assert.deepEqual(result, { token: 'recovered-token' });
            assert.equal(config.runtimeAccessToken, 'recovered-token');
        });
        it('returns 401 when bridge refresh has no new token for running agent', 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/refresh-token') {
                    // Bridge says no new token available (409 conflict)
                    return {
                        ok: false,
                        status: 409,
                        statusText: 'Conflict',
                        json: async () => ({ error: 'token_unchanged' }),
                    };
                }
                return {
                    ok: false,
                    status: 401,
                    statusText: 'Unauthorized',
                };
            };
            await assert.rejects(() => client.callTool('get_memory_stats', { agentId: 'agent-1' }), /MCP tool get_memory_stats call failed: 401 Unauthorized/);
            // 1 initial MCP call + 1 refresh attempt + 0 retries (refresh failed)
            assert.equal(fetchCalls.filter(([url]) => url === 'http://127.0.0.1:8080/mcp/call').length, 1);
            assert.equal(fetchCalls.filter(([url]) => url === 'http://127.0.0.1:18081/runtime/binding/refresh-token').length, 1);
            assert.equal(config.runtimeAccessToken, 'stale-token');
        });
        it('returns 404 from bridge refresh when agent has no cached token', 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/refresh-token') {
                    // Bridge has no cached token for this agent
                    return {
                        ok: false,
                        status: 404,
                        statusText: 'Not Found',
                        json: async () => ({ error: 'no cached token for agent' }),
                    };
                }
                return {
                    ok: false,
                    status: 401,
                    statusText: 'Unauthorized',
                };
            };
            await assert.rejects(() => client.callTool('get_profile', { agentId: 'agent-unknown' }), /MCP tool get_profile call failed: 401 Unauthorized/);
            // 1 initial MCP call + 1 refresh attempt (404) + 0 retries
            assert.equal(fetchCalls.filter(([url]) => url === 'http://127.0.0.1:8080/mcp/call').length, 1);
            assert.equal(fetchCalls.filter(([url]) => url === 'http://127.0.0.1:18081/runtime/binding/refresh-token').length, 1);
            assert.equal(config.runtimeAccessToken, 'stale-token');
        });
        it('does not retry 401 after bridge refresh returns same token', 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/refresh-token') {
                    // Bridge returns same token
                    return {
                        ok: true,
                        status: 200,
                        statusText: 'OK',
                        json: async () => ({ runtimeAccessToken: 'stale-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/);
            // 1 initial MCP call + 1 refresh + 0 retries (same token, not updated)
            assert.equal(fetchCalls.filter(([url]) => url === 'http://127.0.0.1:8080/mcp/call').length, 1);
            assert.equal(fetchCalls.filter(([url]) => url === 'http://127.0.0.1:18081/runtime/binding/refresh-token').length, 1);
        });
        it('retries refresh on second call if first call 401 with different fresh token', 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 refreshCount = 0;
            global.fetch = async (url, options) => {
                fetchCalls.push([url.toString(), options]);
                if (url.toString() === 'http://127.0.0.1:18081/runtime/binding/refresh-token') {
                    refreshCount += 1;
                    return {
                        ok: true,
                        status: 200,
                        statusText: 'OK',
                        json: async () => ({ runtimeAccessToken: `fresh-token-${refreshCount}` }),
                    };
                }
                const token = options.headers['X-Runtime-Token'];
                if (token && token.startsWith('fresh-token-')) {
                    return {
                        ok: false,
                        status: 401,
                        statusText: 'Unauthorized',
                    };
                }
                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/);
            // Second call also tries refresh
            await assert.rejects(() => client.callTool('poll_message', { agentId: 'agent-1' }), /MCP tool poll_message call failed: 401 Unauthorized/);
            assert.equal(config.runtimeAccessToken, 'fresh-token-2');
            assert.equal(fetchCalls.filter(([url]) => url === 'http://127.0.0.1:18081/runtime/binding/refresh-token').length, 2);
        });
        it('shares one refresh promise among concurrent 401 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);
            let resolveRefresh = () => { };
            const refreshGate = new Promise((resolve) => {
                resolveRefresh = resolve;
            });
            let refreshCallCount = 0;
            let freshTokenMcpCallCount = 0;
            global.fetch = async (url, options) => {
                fetchCalls.push([url.toString(), options]);
                if (url.toString() === 'http://127.0.0.1:18081/runtime/binding/refresh-token') {
                    refreshCallCount += 1;
                    await refreshGate;
                    return {
                        ok: true,
                        status: 200,
                        statusText: 'OK',
                        json: async () => ({ runtimeAccessToken: 'fresh-but-rejected-token' }),
                    };
                }
                const requestHeaders = (options?.headers ?? {});
                if (requestHeaders.Authorization === 'Bearer fresh-but-rejected-token') {
                    freshTokenMcpCallCount += 1;
                }
                return {
                    ok: false,
                    status: 401,
                    statusText: 'Unauthorized',
                };
            };
            const firstCall = client.callTool('discover_group_rooms', { agentId: 'agent-1' });
            // Wait for the refresh to start
            await new Promise((resolve) => setTimeout(resolve, 20));
            // Second call should share the same refresh promise
            const secondCall = client.callTool('poll_message', { agentId: 'agent-1' });
            resolveRefresh();
            await assert.rejects(() => firstCall, /MCP tool discover_group_rooms call failed: 401 Unauthorized/);
            await assert.rejects(() => secondCall, /MCP tool poll_message call failed: 401 Unauthorized/);
            assert.equal(config.runtimeAccessToken, 'fresh-but-rejected-token');
            // Only one refresh call to bridge
            assert.equal(refreshCallCount, 1);
            assert.equal(fetchCalls.filter(([url]) => url === 'http://127.0.0.1:18081/runtime/binding/refresh-token').length, 1);
            // Both tools retried once with fresh token
            assert.equal(freshTokenMcpCallCount, 2);
        });
        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,
                mcpRetryMaxAccumulatedMs: 0,
            };
            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,
                mcpRetryMaxAccumulatedMs: 0,
            };
            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,
                mcpRetryMaxAccumulatedMs: 0,
            };
            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);
        });
        it('retries on 5xx response until deadline exhausted', async () => {
            const config = {
                serverUrl: 'ws://localhost:8080/ws/agent',
                heartbeatInterval: 15000,
                reconnectInterval: 200,
                maxReconnectAttempts: -1,
                mcpRetryIntervalMs: 1,
                mcpRetryMaxAccumulatedMs: 20,
            };
            const client = new HttpClient(config);
            global.fetch = async (url, options) => {
                fetchCalls.push([url.toString(), options]);
                return {
                    ok: false,
                    status: 503,
                    statusText: 'Service Unavailable',
                };
            };
            await assert.rejects(() => client.callTool('test-tool', {}), /MCP tool test-tool call failed: 503 Service Unavailable/);
            // Multiple fetch calls due to retries
            assert.ok(fetchCalls.length > 1);
        });
        it('retries on network error until deadline exhausted', async () => {
            const config = {
                serverUrl: 'ws://localhost:8080/ws/agent',
                heartbeatInterval: 15000,
                reconnectInterval: 200,
                maxReconnectAttempts: -1,
                mcpRetryIntervalMs: 1,
                mcpRetryMaxAccumulatedMs: 20,
            };
            const client = new HttpClient(config);
            global.fetch = async (url, options) => {
                fetchCalls.push([url.toString(), options]);
                throw new Error('Network error');
            };
            await assert.rejects(() => client.callTool('test-tool', {}), /Network error/);
            assert.ok(fetchCalls.length > 1);
        });
        it('retries on timeout until deadline exhausted', async () => {
            const config = {
                serverUrl: 'ws://localhost:8080/ws/agent',
                heartbeatInterval: 15000,
                reconnectInterval: 200,
                maxReconnectAttempts: -1,
                mcpHttpRequestTimeoutMs: 10,
                mcpRetryIntervalMs: 1,
                mcpRetryMaxAccumulatedMs: 30,
            };
            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/);
            // Multiple fetch calls due to retries
            assert.ok(fetchCalls.length > 1);
        });
        it('does not retry on 404 client error', async () => {
            const config = {
                serverUrl: 'ws://localhost:8080/ws/agent',
                heartbeatInterval: 15000,
                reconnectInterval: 200,
                maxReconnectAttempts: -1,
                mcpRetryIntervalMs: 100,
                mcpRetryMaxAccumulatedMs: 30000,
            };
            const client = new HttpClient(config);
            global.fetch = async (url, options) => {
                fetchCalls.push([url.toString(), options]);
                return {
                    ok: false,
                    status: 404,
                    statusText: 'Not Found',
                };
            };
            await assert.rejects(() => client.callTool('test-tool', {}), /MCP tool test-tool call failed: 404 Not Found/);
            // Only 1 fetch call — no retry on 4xx
            assert.equal(fetchCalls.length, 1);
        });
        it('does not retry on business error in response body', async () => {
            const config = {
                serverUrl: 'ws://localhost:8080/ws/agent',
                heartbeatInterval: 15000,
                reconnectInterval: 200,
                maxReconnectAttempts: -1,
                mcpRetryMaxAccumulatedMs: 0,
            };
            const client = new HttpClient(config);
            global.fetch = async (url, options) => {
                fetchCalls.push([url.toString(), options]);
                return {
                    ok: true,
                    json: async () => ({ error: 'Tool execution failed' }),
                };
            };
            await assert.rejects(() => client.callTool('test-tool', {}), /MCP tool test-tool failed: Tool execution failed/);
            assert.equal(fetchCalls.length, 1);
        });
    });
});
//# sourceMappingURL=http-client.test.js.map