From cc7a450e4d0e62b380b133668cadb6c662838f54 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 06:45:30 +0000 Subject: [PATCH 01/12] Initial plan From d05602112e21d4b8aaea9b7e668673aa5cfcba6d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 06:58:18 +0000 Subject: [PATCH 02/12] fix: remove default wiki llm timeout ceiling Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/af129945-1e0e-4d94-8676-877470c4574c --- README.md | 2 +- .../skills/gitnexus-cli/SKILL.md | 2 +- gitnexus/src/cli/index.ts | 2 +- gitnexus/src/core/wiki/llm-client.ts | 15 ++--- gitnexus/test/unit/wiki-llm-client.test.ts | 57 +++++++++++++++++++ 5 files changed, 68 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 5909554e95..5e470221fb 100644 --- a/README.md +++ b/README.md @@ -725,7 +725,7 @@ gitnexus wiki --force # Increase the timeout or retries for large codebase or slow LLM providers -gitnexus wiki --timeout # Per-attempt LLM request timeout in seconds (default: 60) +gitnexus wiki --timeout # Per-attempt LLM request timeout in seconds (default: disabled) gitnexus wiki --retries # Max LLM retry attempts per request (default: 3) ``` diff --git a/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md b/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md index 11945b8cce..6e3ceb753b 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md +++ b/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md @@ -62,7 +62,7 @@ Generates repository documentation from the knowledge graph using an LLM. Requir | `--api-key ` | LLM API key | | `--concurrency ` | Parallel LLM calls (default: 3) | | `--gist` | Publish wiki as a public GitHub Gist | -| `--timeout ` | Per-attempt LLM request timeout in seconds (default: 60) | +| `--timeout ` | Per-attempt LLM request timeout in seconds (default: disabled) | | `--retries ` | Max LLM retry attempts per request (default: 3) | ### list — Show all indexed repos diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 4b009e4aa5..7ae8d2736f 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -161,7 +161,7 @@ program ) .option('--no-reasoning-model', 'Disable reasoning model mode (overrides saved config)') .option('--concurrency ', 'Parallel LLM calls (default: 3)', '3') - .option('--timeout ', 'Per-attempt LLM request timeout in seconds (default: 60)') + .option('--timeout ', 'Per-attempt LLM request timeout in seconds (default: disabled)') .option('--retries ', 'Max LLM retry attempts per request (default: 3)') .option('--gist', 'Publish wiki as a public GitHub Gist after generation') .option('-v, --verbose', 'Enable verbose output (show LLM commands and responses)') diff --git a/gitnexus/src/core/wiki/llm-client.ts b/gitnexus/src/core/wiki/llm-client.ts index 40ef831bf1..fe73dad856 100644 --- a/gitnexus/src/core/wiki/llm-client.ts +++ b/gitnexus/src/core/wiki/llm-client.ts @@ -23,7 +23,7 @@ export interface LLMConfig { apiVersion?: string; /** When true, strips sampling params and uses max_completion_tokens instead of max_tokens */ isReasoningModel?: boolean; - /** Per-attempt fetch timeout in ms (default: 60_000). */ + /** Per-attempt fetch timeout in ms. Omit to disable request timeouts. */ requestTimeoutMs?: number; /** Max fetch attempts before giving up (default: 3). */ maxAttempts?: number; @@ -237,12 +237,13 @@ export async function callLLM( ...authHeaders, }, body: JSON.stringify(body), - // Per-attempt timeout. Without this each retry can hang - // indefinitely on a frozen TCP connection — the per-call - // signal is the only timeout `resilientFetch` honors; - // `capDelayMs` only bounds the *backoff* between attempts. - // Default 60s; raise via --timeout for slow models or large pages. - signal: AbortSignal.timeout(config.requestTimeoutMs ?? 60_000), + // Per-attempt timeout is opt-in for wiki generation. Large local + // model runs can legitimately take well over a minute, so the + // default runtime path must not impose a hidden 60s ceiling. + signal: + config.requestTimeoutMs !== undefined + ? AbortSignal.timeout(config.requestTimeoutMs) + : undefined, }, { breakerKey: `wiki-llm-${new URL(url).host}`, diff --git a/gitnexus/test/unit/wiki-llm-client.test.ts b/gitnexus/test/unit/wiki-llm-client.test.ts index 52b633566f..8a20ee4d69 100644 --- a/gitnexus/test/unit/wiki-llm-client.test.ts +++ b/gitnexus/test/unit/wiki-llm-client.test.ts @@ -237,6 +237,63 @@ describe('callLLM — reasoning model params', () => { }); }); +describe('callLLM — timeout handling', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('does not apply a default timeout when requestTimeoutMs is omitted', async () => { + const fetchSpy = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ choices: [{ message: { content: 'answer' } }], usage: {} }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchSpy); + const timeoutSpy = vi.spyOn(AbortSignal, 'timeout'); + + const { callLLM } = await import('../../src/core/wiki/llm-client.js'); + await callLLM('test', { + apiKey: 'sk-test', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4o', + maxTokens: 500, + temperature: 0, + }); + + expect(timeoutSpy).not.toHaveBeenCalled(); + const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; + expect(init.signal).toBeUndefined(); + }); + + it('applies an explicit timeout when requestTimeoutMs is provided', async () => { + const fetchSpy = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ choices: [{ message: { content: 'answer' } }], usage: {} }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchSpy); + const timeoutSignal = new AbortController().signal; + const timeoutSpy = vi.spyOn(AbortSignal, 'timeout').mockReturnValue(timeoutSignal); + + const { callLLM } = await import('../../src/core/wiki/llm-client.js'); + await callLLM('test', { + apiKey: 'sk-test', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4o', + maxTokens: 500, + temperature: 0, + requestTimeoutMs: 120_000, + }); + + expect(timeoutSpy).toHaveBeenCalledWith(120_000); + const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; + expect(init.signal).toBe(timeoutSignal); + }); +}); + describe('callLLM — Azure content_filter error', () => { afterEach(() => vi.unstubAllGlobals()); From f8981ed4ff795715868cb0601dceeec3d4a1d62d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 07:23:32 +0000 Subject: [PATCH 03/12] fix: validate invalid wiki timeout values Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/2f7def72-828c-419c-a5db-1bf1e2f10203 --- gitnexus/src/cli/wiki.ts | 23 +++++++- gitnexus/test/unit/wiki-flags.test.ts | 82 +++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 3 deletions(-) diff --git a/gitnexus/src/cli/wiki.ts b/gitnexus/src/cli/wiki.ts index 8d9da9572c..84aeb87bf0 100644 --- a/gitnexus/src/cli/wiki.ts +++ b/gitnexus/src/cli/wiki.ts @@ -37,6 +37,15 @@ export interface WikiCommandOptions { retries?: string; } +function parsePositiveIntegerOption(value: string | undefined, flag: string): number | undefined { + if (value === undefined) return undefined; + const trimmed = value.trim(); + if (!/^[1-9]\d*$/.test(trimmed)) { + throw new Error(`${flag} must be a positive integer`); + } + return parseInt(trimmed, 10); +} + /** * Prompt the user for input via stdin. */ @@ -127,6 +136,15 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio return; } + let timeoutSeconds: number | undefined; + try { + timeoutSeconds = parsePositiveIntegerOption(options?.timeout, '--timeout'); + } catch (error) { + console.log(` Error: ${(error as Error).message}\n`); + process.exitCode = 1; + return; + } + // ── Resolve LLM config (with interactive fallback) ───────────────── // Save any CLI overrides immediately if ( @@ -350,9 +368,8 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio } // ── Apply per-run overrides not saved to config ──────────────────── - if (options?.timeout) { - const secs = parseInt(options.timeout, 10); - if (!isNaN(secs) && secs > 0) llmConfig.requestTimeoutMs = secs * 1000; + if (timeoutSeconds !== undefined) { + llmConfig.requestTimeoutMs = timeoutSeconds * 1000; } if (options?.retries) { const n = parseInt(options.retries, 10); diff --git a/gitnexus/test/unit/wiki-flags.test.ts b/gitnexus/test/unit/wiki-flags.test.ts index af19c676d3..013251771d 100644 --- a/gitnexus/test/unit/wiki-flags.test.ts +++ b/gitnexus/test/unit/wiki-flags.test.ts @@ -264,6 +264,88 @@ describe('WikiGenerator --review mode', () => { }); }); +describe('wikiCommand --timeout validation', () => { + const originalExitCode = process.exitCode; + + beforeEach(() => { + vi.resetModules(); + process.exitCode = undefined; + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.doUnmock('../../src/storage/git.js'); + vi.doUnmock('../../src/storage/repo-manager.js'); + vi.doUnmock('../../src/core/wiki/llm-client.js'); + vi.doUnmock('../../src/core/wiki/generator.js'); + vi.doUnmock('cli-progress'); + process.exitCode = originalExitCode; + }); + + it.each(['0', '-1', 'abc'])( + 'rejects invalid --timeout value %s before starting generation', + async (timeout) => { + const generatorCtor = vi.fn().mockImplementation(() => ({ + run: vi.fn(), + })); + + vi.doMock('../../src/storage/git.js', () => ({ + getGitRoot: vi.fn(), + isGitRepo: vi.fn().mockReturnValue(true), + })); + vi.doMock('../../src/storage/repo-manager.js', () => ({ + getStoragePaths: vi + .fn() + .mockReturnValue({ storagePath: '/tmp/wiki-storage', lbugPath: '/tmp/wiki-db' }), + loadMeta: vi.fn().mockResolvedValue({ createdAt: '2026-01-01T00:00:00Z' }), + loadCLIConfig: vi.fn().mockResolvedValue({ + apiKey: 'sk-test', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4o', + provider: 'openai', + }), + saveCLIConfig: vi.fn(), + })); + vi.doMock('../../src/core/wiki/llm-client.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveLLMConfig: vi.fn().mockResolvedValue({ + apiKey: 'sk-test', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4o', + maxTokens: 16_384, + temperature: 0, + provider: 'openai', + }), + }; + }); + vi.doMock('../../src/core/wiki/generator.js', () => ({ + WikiGenerator: generatorCtor, + })); + vi.doMock('cli-progress', () => ({ + default: { + SingleBar: vi.fn().mockImplementation(() => ({ + start: vi.fn(), + update: vi.fn(), + stop: vi.fn(), + })), + Presets: { shades_grey: {} }, + }, + })); + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const { wikiCommand } = await import('../../src/cli/wiki.js'); + + await wikiCommand('/tmp/repo', { timeout }); + + expect(process.exitCode).toBe(1); + expect(generatorCtor).not.toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledWith(' Error: --timeout must be a positive integer\n'); + }, + ); +}); + // ─── CLI config round-trip with cursor provider ────────────────────── describe('CLI config round-trip with cursor provider', () => { From 66522c48c50dab9c503ef7d73f61cdb4aaee241f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 07:25:01 +0000 Subject: [PATCH 04/12] test: cover wiki timeout option mapping Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/2f7def72-828c-419c-a5db-1bf1e2f10203 --- gitnexus/test/unit/wiki-flags.test.ts | 117 ++++++++++++++++++++++++-- 1 file changed, 112 insertions(+), 5 deletions(-) diff --git a/gitnexus/test/unit/wiki-flags.test.ts b/gitnexus/test/unit/wiki-flags.test.ts index 013251771d..17763b6e7e 100644 --- a/gitnexus/test/unit/wiki-flags.test.ts +++ b/gitnexus/test/unit/wiki-flags.test.ts @@ -325,11 +325,13 @@ describe('wikiCommand --timeout validation', () => { })); vi.doMock('cli-progress', () => ({ default: { - SingleBar: vi.fn().mockImplementation(() => ({ - start: vi.fn(), - update: vi.fn(), - stop: vi.fn(), - })), + SingleBar: vi.fn(function () { + return { + start: vi.fn(), + update: vi.fn(), + stop: vi.fn(), + }; + }), Presets: { shades_grey: {} }, }, })); @@ -346,6 +348,111 @@ describe('wikiCommand --timeout validation', () => { ); }); +describe('wikiCommand --timeout mapping', () => { + const originalExitCode = process.exitCode; + + beforeEach(() => { + vi.resetModules(); + process.exitCode = undefined; + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.doUnmock('../../src/storage/git.js'); + vi.doUnmock('../../src/storage/repo-manager.js'); + vi.doUnmock('../../src/core/wiki/llm-client.js'); + vi.doUnmock('../../src/core/wiki/generator.js'); + vi.doUnmock('cli-progress'); + process.exitCode = originalExitCode; + }); + + async function loadWikiCommandHarness() { + let capturedConfig: Record | undefined; + const generatorCtor = vi + .fn() + .mockImplementation(function (_repoPath, _storagePath, _lbugPath, config) { + capturedConfig = config; + return { + run: vi.fn().mockResolvedValue({ mode: 'up-to-date', pagesGenerated: 0 }), + }; + }); + + vi.doMock('../../src/storage/git.js', () => ({ + getGitRoot: vi.fn(), + isGitRepo: vi.fn().mockReturnValue(true), + })); + vi.doMock('../../src/storage/repo-manager.js', () => ({ + getStoragePaths: vi + .fn() + .mockReturnValue({ storagePath: '/tmp/wiki-storage', lbugPath: '/tmp/wiki-db' }), + loadMeta: vi.fn().mockResolvedValue({ createdAt: '2026-01-01T00:00:00Z' }), + loadCLIConfig: vi.fn().mockResolvedValue({ + apiKey: 'sk-test', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4o', + provider: 'openai', + }), + saveCLIConfig: vi.fn(), + })); + vi.doMock('../../src/core/wiki/llm-client.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveLLMConfig: vi.fn().mockResolvedValue({ + apiKey: 'sk-test', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4o', + maxTokens: 16_384, + temperature: 0, + provider: 'openai', + }), + }; + }); + vi.doMock('../../src/core/wiki/generator.js', () => ({ + WikiGenerator: generatorCtor, + })); + vi.doMock('cli-progress', () => ({ + default: { + SingleBar: vi.fn(function () { + return { + start: vi.fn(), + update: vi.fn(), + stop: vi.fn(), + }; + }), + Presets: { shades_grey: {} }, + }, + })); + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const { wikiCommand } = await import('../../src/cli/wiki.js'); + return { + wikiCommand, + generatorCtor, + consoleSpy, + getCapturedConfig: () => capturedConfig, + }; + } + + it('maps --timeout seconds to requestTimeoutMs before constructing WikiGenerator', async () => { + const harness = await loadWikiCommandHarness(); + + await harness.wikiCommand('/tmp/repo', { timeout: '120' }); + + expect(harness.generatorCtor).toHaveBeenCalledTimes(1); + expect(harness.getCapturedConfig()?.requestTimeoutMs).toBe(120_000); + }); + + it('leaves requestTimeoutMs undefined when --timeout is omitted', async () => { + const harness = await loadWikiCommandHarness(); + + await harness.wikiCommand('/tmp/repo', {}); + + expect(harness.generatorCtor).toHaveBeenCalledTimes(1); + expect(harness.getCapturedConfig()?.requestTimeoutMs).toBeUndefined(); + }); +}); + // ─── CLI config round-trip with cursor provider ────────────────────── describe('CLI config round-trip with cursor provider', () => { From 24ad0048a9af2b6f89dc457921f3014b88f31421 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 07:26:09 +0000 Subject: [PATCH 05/12] test: add wiki timeout validation edge cases Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/2f7def72-828c-419c-a5db-1bf1e2f10203 --- gitnexus/test/unit/wiki-flags.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gitnexus/test/unit/wiki-flags.test.ts b/gitnexus/test/unit/wiki-flags.test.ts index 17763b6e7e..2ea6b2c9f8 100644 --- a/gitnexus/test/unit/wiki-flags.test.ts +++ b/gitnexus/test/unit/wiki-flags.test.ts @@ -282,7 +282,7 @@ describe('wikiCommand --timeout validation', () => { process.exitCode = originalExitCode; }); - it.each(['0', '-1', 'abc'])( + it.each(['', ' ', '0', '-1', 'abc'])( 'rejects invalid --timeout value %s before starting generation', async (timeout) => { const generatorCtor = vi.fn().mockImplementation(() => ({ From e85de16bd68652b69f3b9a9779c4a811fe4cbe2d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 07:27:05 +0000 Subject: [PATCH 06/12] test: reject fractional wiki timeout values Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/2f7def72-828c-419c-a5db-1bf1e2f10203 --- gitnexus/test/unit/wiki-flags.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gitnexus/test/unit/wiki-flags.test.ts b/gitnexus/test/unit/wiki-flags.test.ts index 2ea6b2c9f8..780b475c09 100644 --- a/gitnexus/test/unit/wiki-flags.test.ts +++ b/gitnexus/test/unit/wiki-flags.test.ts @@ -282,7 +282,7 @@ describe('wikiCommand --timeout validation', () => { process.exitCode = originalExitCode; }); - it.each(['', ' ', '0', '-1', 'abc'])( + it.each(['', ' ', '0', '-1', 'abc', '3.14'])( 'rejects invalid --timeout value %s before starting generation', async (timeout) => { const generatorCtor = vi.fn().mockImplementation(() => ({ From d74c5dd25b90e00fd96f4da37a51fd91ecb8bf6b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 07:28:14 +0000 Subject: [PATCH 07/12] fix: reject overflowing wiki timeout values Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/2f7def72-828c-419c-a5db-1bf1e2f10203 --- gitnexus/src/cli/wiki.ts | 14 +++++++++++--- gitnexus/test/unit/wiki-flags.test.ts | 9 +++++++-- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/gitnexus/src/cli/wiki.ts b/gitnexus/src/cli/wiki.ts index 84aeb87bf0..ef968b6fad 100644 --- a/gitnexus/src/cli/wiki.ts +++ b/gitnexus/src/cli/wiki.ts @@ -37,13 +37,21 @@ export interface WikiCommandOptions { retries?: string; } -function parsePositiveIntegerOption(value: string | undefined, flag: string): number | undefined { +function parsePositiveIntegerOption( + value: string | undefined, + flag: string, + multiplier = 1, +): number | undefined { if (value === undefined) return undefined; const trimmed = value.trim(); if (!/^[1-9]\d*$/.test(trimmed)) { throw new Error(`${flag} must be a positive integer`); } - return parseInt(trimmed, 10); + const parsed = parseInt(trimmed, 10); + if (parsed > Math.floor(Number.MAX_SAFE_INTEGER / multiplier)) { + throw new Error(`${flag} is too large`); + } + return parsed; } /** @@ -138,7 +146,7 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio let timeoutSeconds: number | undefined; try { - timeoutSeconds = parsePositiveIntegerOption(options?.timeout, '--timeout'); + timeoutSeconds = parsePositiveIntegerOption(options?.timeout, '--timeout', 1000); } catch (error) { console.log(` Error: ${(error as Error).message}\n`); process.exitCode = 1; diff --git a/gitnexus/test/unit/wiki-flags.test.ts b/gitnexus/test/unit/wiki-flags.test.ts index 780b475c09..ff4b5eaa01 100644 --- a/gitnexus/test/unit/wiki-flags.test.ts +++ b/gitnexus/test/unit/wiki-flags.test.ts @@ -266,6 +266,7 @@ describe('WikiGenerator --review mode', () => { describe('wikiCommand --timeout validation', () => { const originalExitCode = process.exitCode; + const tooLargeTimeout = String(Math.floor(Number.MAX_SAFE_INTEGER / 1000) + 1); beforeEach(() => { vi.resetModules(); @@ -282,7 +283,7 @@ describe('wikiCommand --timeout validation', () => { process.exitCode = originalExitCode; }); - it.each(['', ' ', '0', '-1', 'abc', '3.14'])( + it.each(['', ' ', '0', '-1', 'abc', '3.14', tooLargeTimeout])( 'rejects invalid --timeout value %s before starting generation', async (timeout) => { const generatorCtor = vi.fn().mockImplementation(() => ({ @@ -343,7 +344,11 @@ describe('wikiCommand --timeout validation', () => { expect(process.exitCode).toBe(1); expect(generatorCtor).not.toHaveBeenCalled(); - expect(consoleSpy).toHaveBeenCalledWith(' Error: --timeout must be a positive integer\n'); + const expectedMessage = + timeout === tooLargeTimeout + ? ' Error: --timeout is too large\n' + : ' Error: --timeout must be a positive integer\n'; + expect(consoleSpy).toHaveBeenCalledWith(expectedMessage); }, ); }); From fae3dbade5425a674d8b40d3cafab8aa83d6a6a6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 07:42:42 +0000 Subject: [PATCH 08/12] fix: surface engaged wiki timeout errors Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/557eb110-9ffd-4771-a7a8-782b1934b3d2 --- gitnexus/src/cli/wiki.ts | 2 + gitnexus/src/core/wiki/llm-client.ts | 17 ++++ gitnexus/test/unit/wiki-flags.test.ts | 91 ++++++++++++++++++++++ gitnexus/test/unit/wiki-llm-client.test.ts | 21 +++++ 4 files changed, 131 insertions(+) diff --git a/gitnexus/src/cli/wiki.ts b/gitnexus/src/cli/wiki.ts index ef968b6fad..9e499b4b30 100644 --- a/gitnexus/src/cli/wiki.ts +++ b/gitnexus/src/cli/wiki.ts @@ -588,6 +588,8 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio if (err.message?.includes('No source files')) { console.log(`\n ${err.message}\n`); + } else if (err.message?.includes('LLM request timed out after')) { + console.log(`\n Timeout: ${err.message}\n`); } else if (err.message?.includes('content filter')) { // Content filter block — actionable message console.log(`\n Content Filter: ${err.message}\n`); diff --git a/gitnexus/src/core/wiki/llm-client.ts b/gitnexus/src/core/wiki/llm-client.ts index fe73dad856..b5997af6da 100644 --- a/gitnexus/src/core/wiki/llm-client.ts +++ b/gitnexus/src/core/wiki/llm-client.ts @@ -81,6 +81,13 @@ export function estimateTokens(text: string): number { return Math.ceil(text.length / 4); } +function formatTimeoutDuration(timeoutMs: number): string { + if (timeoutMs >= 1000 && timeoutMs % 1000 === 0) { + return `${timeoutMs / 1000}s`; + } + return `${timeoutMs}ms`; +} + /** * Validate that a base URL supplied for LLM API calls is a safe HTTP/HTTPS * endpoint (CWE-918 / CodeQL js/http-to-file-access). @@ -262,6 +269,16 @@ export async function callLLM( `LLM API error (${err.response.status} after retries): ${errorText.slice(0, 500)}`, ); } + if ( + config.requestTimeoutMs !== undefined && + err instanceof DOMException && + (err.name === 'TimeoutError' || err.name === 'AbortError') + ) { + throw new Error( + `LLM request timed out after ${formatTimeoutDuration(config.requestTimeoutMs)}. ` + + 'Increase --timeout or omit it to disable the per-attempt timeout.', + ); + } throw err; } diff --git a/gitnexus/test/unit/wiki-flags.test.ts b/gitnexus/test/unit/wiki-flags.test.ts index ff4b5eaa01..c13e9082b3 100644 --- a/gitnexus/test/unit/wiki-flags.test.ts +++ b/gitnexus/test/unit/wiki-flags.test.ts @@ -458,6 +458,97 @@ describe('wikiCommand --timeout mapping', () => { }); }); +describe('wikiCommand timeout messaging', () => { + const originalExitCode = process.exitCode; + + beforeEach(() => { + vi.resetModules(); + process.exitCode = undefined; + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.doUnmock('../../src/storage/git.js'); + vi.doUnmock('../../src/storage/repo-manager.js'); + vi.doUnmock('../../src/core/wiki/llm-client.js'); + vi.doUnmock('../../src/core/wiki/generator.js'); + vi.doUnmock('cli-progress'); + process.exitCode = originalExitCode; + }); + + it('surfaces a dedicated timeout message when wiki generation hits the configured timeout', async () => { + const generatorCtor = vi.fn().mockImplementation(function () { + return { + run: vi + .fn() + .mockRejectedValue( + new Error( + 'LLM request timed out after 120s. Increase --timeout or omit it to disable the per-attempt timeout.', + ), + ), + }; + }); + + vi.doMock('../../src/storage/git.js', () => ({ + getGitRoot: vi.fn(), + isGitRepo: vi.fn().mockReturnValue(true), + })); + vi.doMock('../../src/storage/repo-manager.js', () => ({ + getStoragePaths: vi + .fn() + .mockReturnValue({ storagePath: '/tmp/wiki-storage', lbugPath: '/tmp/wiki-db' }), + loadMeta: vi.fn().mockResolvedValue({ createdAt: '2026-01-01T00:00:00Z' }), + loadCLIConfig: vi.fn().mockResolvedValue({ + apiKey: 'sk-test', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4o', + provider: 'openai', + }), + saveCLIConfig: vi.fn(), + })); + vi.doMock('../../src/core/wiki/llm-client.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveLLMConfig: vi.fn().mockResolvedValue({ + apiKey: 'sk-test', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4o', + maxTokens: 16_384, + temperature: 0, + provider: 'openai', + }), + }; + }); + vi.doMock('../../src/core/wiki/generator.js', () => ({ + WikiGenerator: generatorCtor, + })); + vi.doMock('cli-progress', () => ({ + default: { + SingleBar: vi.fn(function () { + return { + start: vi.fn(), + update: vi.fn(), + stop: vi.fn(), + }; + }), + Presets: { shades_grey: {} }, + }, + })); + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const { wikiCommand } = await import('../../src/cli/wiki.js'); + + await wikiCommand('/tmp/repo', { timeout: '120' }); + + expect(process.exitCode).toBe(1); + expect(generatorCtor).toHaveBeenCalledTimes(1); + expect(consoleSpy).toHaveBeenCalledWith( + '\n Timeout: LLM request timed out after 120s. Increase --timeout or omit it to disable the per-attempt timeout.\n', + ); + }); +}); + // ─── CLI config round-trip with cursor provider ────────────────────── describe('CLI config round-trip with cursor provider', () => { diff --git a/gitnexus/test/unit/wiki-llm-client.test.ts b/gitnexus/test/unit/wiki-llm-client.test.ts index 8a20ee4d69..669eca23d3 100644 --- a/gitnexus/test/unit/wiki-llm-client.test.ts +++ b/gitnexus/test/unit/wiki-llm-client.test.ts @@ -292,6 +292,27 @@ describe('callLLM — timeout handling', () => { const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; expect(init.signal).toBe(timeoutSignal); }); + + it('surfaces a clear timeout error when the request timeout fires', async () => { + const fetchSpy = vi + .fn() + .mockRejectedValue(new DOMException('The operation timed out.', 'TimeoutError')); + vi.stubGlobal('fetch', fetchSpy); + + const { callLLM } = await import('../../src/core/wiki/llm-client.js'); + await expect( + callLLM('test', { + apiKey: 'sk-test', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4o', + maxTokens: 500, + temperature: 0, + requestTimeoutMs: 120_000, + }), + ).rejects.toThrow( + 'LLM request timed out after 120s. Increase --timeout or omit it to disable the per-attempt timeout.', + ); + }); }); describe('callLLM — Azure content_filter error', () => { From e4fbfe755978d2c46f393c51078e2e265c0c1c5c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 07:43:35 +0000 Subject: [PATCH 09/12] test: cover wiki timeout ms messaging Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/557eb110-9ffd-4771-a7a8-782b1934b3d2 --- gitnexus/test/unit/wiki-llm-client.test.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/gitnexus/test/unit/wiki-llm-client.test.ts b/gitnexus/test/unit/wiki-llm-client.test.ts index 669eca23d3..0794ef4a82 100644 --- a/gitnexus/test/unit/wiki-llm-client.test.ts +++ b/gitnexus/test/unit/wiki-llm-client.test.ts @@ -313,6 +313,27 @@ describe('callLLM — timeout handling', () => { 'LLM request timed out after 120s. Increase --timeout or omit it to disable the per-attempt timeout.', ); }); + + it('surfaces millisecond timeout durations when the timeout is not a whole second', async () => { + const fetchSpy = vi + .fn() + .mockRejectedValue(new DOMException('The operation timed out.', 'TimeoutError')); + vi.stubGlobal('fetch', fetchSpy); + + const { callLLM } = await import('../../src/core/wiki/llm-client.js'); + await expect( + callLLM('test', { + apiKey: 'sk-test', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4o', + maxTokens: 500, + temperature: 0, + requestTimeoutMs: 1_500, + }), + ).rejects.toThrow( + 'LLM request timed out after 1500ms. Increase --timeout or omit it to disable the per-attempt timeout.', + ); + }); }); describe('callLLM — Azure content_filter error', () => { From 523f3a06a4b1ff9cdf4b40b9384d6d4c28e7c22c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 07:44:29 +0000 Subject: [PATCH 10/12] fix: harden wiki timeout error detection Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/557eb110-9ffd-4771-a7a8-782b1934b3d2 --- gitnexus/src/core/wiki/llm-client.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/gitnexus/src/core/wiki/llm-client.ts b/gitnexus/src/core/wiki/llm-client.ts index b5997af6da..340c8a95cd 100644 --- a/gitnexus/src/core/wiki/llm-client.ts +++ b/gitnexus/src/core/wiki/llm-client.ts @@ -88,6 +88,12 @@ function formatTimeoutDuration(timeoutMs: number): string { return `${timeoutMs}ms`; } +function isTimeoutLikeError(err: unknown): boolean { + if (!(err instanceof Error)) return false; + if (err.name === 'TimeoutError' || err.name === 'AbortError') return true; + return /timed?\s*out|abort/i.test(err.message); +} + /** * Validate that a base URL supplied for LLM API calls is a safe HTTP/HTTPS * endpoint (CWE-918 / CodeQL js/http-to-file-access). @@ -269,11 +275,7 @@ export async function callLLM( `LLM API error (${err.response.status} after retries): ${errorText.slice(0, 500)}`, ); } - if ( - config.requestTimeoutMs !== undefined && - err instanceof DOMException && - (err.name === 'TimeoutError' || err.name === 'AbortError') - ) { + if (config.requestTimeoutMs !== undefined && isTimeoutLikeError(err)) { throw new Error( `LLM request timed out after ${formatTimeoutDuration(config.requestTimeoutMs)}. ` + 'Increase --timeout or omit it to disable the per-attempt timeout.', From ce17a63961b4fd92e02d242b10a0ab56891197ba Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 07:45:45 +0000 Subject: [PATCH 11/12] fix: match timeout-like wiki errors robustly Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/557eb110-9ffd-4771-a7a8-782b1934b3d2 --- gitnexus/src/core/wiki/llm-client.ts | 2 +- gitnexus/test/unit/wiki-llm-client.test.ts | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/gitnexus/src/core/wiki/llm-client.ts b/gitnexus/src/core/wiki/llm-client.ts index 340c8a95cd..c08db222f9 100644 --- a/gitnexus/src/core/wiki/llm-client.ts +++ b/gitnexus/src/core/wiki/llm-client.ts @@ -91,7 +91,7 @@ function formatTimeoutDuration(timeoutMs: number): string { function isTimeoutLikeError(err: unknown): boolean { if (!(err instanceof Error)) return false; if (err.name === 'TimeoutError' || err.name === 'AbortError') return true; - return /timed?\s*out|abort/i.test(err.message); + return /time(d)?\s*out|timeout|abort/i.test(err.message); } /** diff --git a/gitnexus/test/unit/wiki-llm-client.test.ts b/gitnexus/test/unit/wiki-llm-client.test.ts index 0794ef4a82..3107796cee 100644 --- a/gitnexus/test/unit/wiki-llm-client.test.ts +++ b/gitnexus/test/unit/wiki-llm-client.test.ts @@ -334,6 +334,27 @@ describe('callLLM — timeout handling', () => { 'LLM request timed out after 1500ms. Increase --timeout or omit it to disable the per-attempt timeout.', ); }); + + it('surfaces the same timeout message for timeout-like non-DOM errors', async () => { + const fetchSpy = vi + .fn() + .mockRejectedValue(new Error('request timed out while waiting for response')); + vi.stubGlobal('fetch', fetchSpy); + + const { callLLM } = await import('../../src/core/wiki/llm-client.js'); + await expect( + callLLM('test', { + apiKey: 'sk-test', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4o', + maxTokens: 500, + temperature: 0, + requestTimeoutMs: 120_000, + }), + ).rejects.toThrow( + 'LLM request timed out after 120s. Increase --timeout or omit it to disable the per-attempt timeout.', + ); + }); }); describe('callLLM — Azure content_filter error', () => { From b83c9ccbb95301a7a69752c9e6c6eb30d6058eed Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 08:32:04 +0000 Subject: [PATCH 12/12] fix: address latest wiki timeout review follow-ups Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/d5d9ae3e-75fa-48ab-8709-3ade04f4827c --- README.md | 2 +- .../skills/gitnexus-cli/SKILL.md | 2 +- gitnexus/src/cli/index.ts | 2 +- gitnexus/src/cli/wiki.ts | 7 +- gitnexus/src/core/wiki/llm-client.ts | 6 +- gitnexus/test/unit/wiki-flags.test.ts | 97 ++++++++++++++++++- gitnexus/test/unit/wiki-llm-client.test.ts | 23 ++++- 7 files changed, 125 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 5e470221fb..8287901e81 100644 --- a/README.md +++ b/README.md @@ -725,7 +725,7 @@ gitnexus wiki --force # Increase the timeout or retries for large codebase or slow LLM providers -gitnexus wiki --timeout # Per-attempt LLM request timeout in seconds (default: disabled) +gitnexus wiki --timeout # LLM request timeout in seconds (default: disabled) gitnexus wiki --retries # Max LLM retry attempts per request (default: 3) ``` diff --git a/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md b/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md index 6e3ceb753b..f21eaa4151 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md +++ b/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md @@ -62,7 +62,7 @@ Generates repository documentation from the knowledge graph using an LLM. Requir | `--api-key ` | LLM API key | | `--concurrency ` | Parallel LLM calls (default: 3) | | `--gist` | Publish wiki as a public GitHub Gist | -| `--timeout ` | Per-attempt LLM request timeout in seconds (default: disabled) | +| `--timeout ` | LLM request timeout in seconds (default: disabled) | | `--retries ` | Max LLM retry attempts per request (default: 3) | ### list — Show all indexed repos diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 7ae8d2736f..80a027065a 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -161,7 +161,7 @@ program ) .option('--no-reasoning-model', 'Disable reasoning model mode (overrides saved config)') .option('--concurrency ', 'Parallel LLM calls (default: 3)', '3') - .option('--timeout ', 'Per-attempt LLM request timeout in seconds (default: disabled)') + .option('--timeout ', 'LLM request timeout in seconds (default: disabled)') .option('--retries ', 'Max LLM retry attempts per request (default: 3)') .option('--gist', 'Publish wiki as a public GitHub Gist after generation') .option('-v, --verbose', 'Enable verbose output (show LLM commands and responses)') diff --git a/gitnexus/src/cli/wiki.ts b/gitnexus/src/cli/wiki.ts index 9e499b4b30..6fe32f4c6e 100644 --- a/gitnexus/src/cli/wiki.ts +++ b/gitnexus/src/cli/wiki.ts @@ -145,8 +145,10 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio } let timeoutSeconds: number | undefined; + let retries: number | undefined; try { timeoutSeconds = parsePositiveIntegerOption(options?.timeout, '--timeout', 1000); + retries = parsePositiveIntegerOption(options?.retries, '--retries'); } catch (error) { console.log(` Error: ${(error as Error).message}\n`); process.exitCode = 1; @@ -379,9 +381,8 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio if (timeoutSeconds !== undefined) { llmConfig.requestTimeoutMs = timeoutSeconds * 1000; } - if (options?.retries) { - const n = parseInt(options.retries, 10); - if (!isNaN(n) && n > 0) llmConfig.maxAttempts = n; + if (retries !== undefined) { + llmConfig.maxAttempts = retries; } // ── Setup progress bar with elapsed timer ────────────────────────── diff --git a/gitnexus/src/core/wiki/llm-client.ts b/gitnexus/src/core/wiki/llm-client.ts index c08db222f9..72948b6b05 100644 --- a/gitnexus/src/core/wiki/llm-client.ts +++ b/gitnexus/src/core/wiki/llm-client.ts @@ -91,7 +91,7 @@ function formatTimeoutDuration(timeoutMs: number): string { function isTimeoutLikeError(err: unknown): boolean { if (!(err instanceof Error)) return false; if (err.name === 'TimeoutError' || err.name === 'AbortError') return true; - return /time(d)?\s*out|timeout|abort/i.test(err.message); + return /time(d)?\s*out|timeout/i.test(err.message); } /** @@ -250,7 +250,7 @@ export async function callLLM( ...authHeaders, }, body: JSON.stringify(body), - // Per-attempt timeout is opt-in for wiki generation. Large local + // Request timeout is opt-in for wiki generation. Large local // model runs can legitimately take well over a minute, so the // default runtime path must not impose a hidden 60s ceiling. signal: @@ -278,7 +278,7 @@ export async function callLLM( if (config.requestTimeoutMs !== undefined && isTimeoutLikeError(err)) { throw new Error( `LLM request timed out after ${formatTimeoutDuration(config.requestTimeoutMs)}. ` + - 'Increase --timeout or omit it to disable the per-attempt timeout.', + 'Increase --timeout or omit it to disable the request timeout.', ); } throw err; diff --git a/gitnexus/test/unit/wiki-flags.test.ts b/gitnexus/test/unit/wiki-flags.test.ts index c13e9082b3..891c9e77f9 100644 --- a/gitnexus/test/unit/wiki-flags.test.ts +++ b/gitnexus/test/unit/wiki-flags.test.ts @@ -353,6 +353,90 @@ describe('wikiCommand --timeout validation', () => { ); }); +describe('wikiCommand --retries validation', () => { + const originalExitCode = process.exitCode; + + beforeEach(() => { + vi.resetModules(); + process.exitCode = undefined; + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.doUnmock('../../src/storage/git.js'); + vi.doUnmock('../../src/storage/repo-manager.js'); + vi.doUnmock('../../src/core/wiki/llm-client.js'); + vi.doUnmock('../../src/core/wiki/generator.js'); + vi.doUnmock('cli-progress'); + process.exitCode = originalExitCode; + }); + + it.each(['', ' ', '0', '-1', 'abc', '3.14'])( + 'rejects invalid --retries value %s before starting generation', + async (retries) => { + const generatorCtor = vi.fn().mockImplementation(() => ({ + run: vi.fn(), + })); + + vi.doMock('../../src/storage/git.js', () => ({ + getGitRoot: vi.fn(), + isGitRepo: vi.fn().mockReturnValue(true), + })); + vi.doMock('../../src/storage/repo-manager.js', () => ({ + getStoragePaths: vi + .fn() + .mockReturnValue({ storagePath: '/tmp/wiki-storage', lbugPath: '/tmp/wiki-db' }), + loadMeta: vi.fn().mockResolvedValue({ createdAt: '2026-01-01T00:00:00Z' }), + loadCLIConfig: vi.fn().mockResolvedValue({ + apiKey: 'sk-test', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4o', + provider: 'openai', + }), + saveCLIConfig: vi.fn(), + })); + vi.doMock('../../src/core/wiki/llm-client.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveLLMConfig: vi.fn().mockResolvedValue({ + apiKey: 'sk-test', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4o', + maxTokens: 16_384, + temperature: 0, + provider: 'openai', + }), + }; + }); + vi.doMock('../../src/core/wiki/generator.js', () => ({ + WikiGenerator: generatorCtor, + })); + vi.doMock('cli-progress', () => ({ + default: { + SingleBar: vi.fn(function () { + return { + start: vi.fn(), + update: vi.fn(), + stop: vi.fn(), + }; + }), + Presets: { shades_grey: {} }, + }, + })); + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const { wikiCommand } = await import('../../src/cli/wiki.js'); + + await wikiCommand('/tmp/repo', { retries }); + + expect(process.exitCode).toBe(1); + expect(generatorCtor).not.toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledWith(' Error: --retries must be a positive integer\n'); + }, + ); +}); + describe('wikiCommand --timeout mapping', () => { const originalExitCode = process.exitCode; @@ -456,6 +540,15 @@ describe('wikiCommand --timeout mapping', () => { expect(harness.generatorCtor).toHaveBeenCalledTimes(1); expect(harness.getCapturedConfig()?.requestTimeoutMs).toBeUndefined(); }); + + it('maps --retries to maxAttempts before constructing WikiGenerator', async () => { + const harness = await loadWikiCommandHarness(); + + await harness.wikiCommand('/tmp/repo', { retries: '5' }); + + expect(harness.generatorCtor).toHaveBeenCalledTimes(1); + expect(harness.getCapturedConfig()?.maxAttempts).toBe(5); + }); }); describe('wikiCommand timeout messaging', () => { @@ -483,7 +576,7 @@ describe('wikiCommand timeout messaging', () => { .fn() .mockRejectedValue( new Error( - 'LLM request timed out after 120s. Increase --timeout or omit it to disable the per-attempt timeout.', + 'LLM request timed out after 120s. Increase --timeout or omit it to disable the request timeout.', ), ), }; @@ -544,7 +637,7 @@ describe('wikiCommand timeout messaging', () => { expect(process.exitCode).toBe(1); expect(generatorCtor).toHaveBeenCalledTimes(1); expect(consoleSpy).toHaveBeenCalledWith( - '\n Timeout: LLM request timed out after 120s. Increase --timeout or omit it to disable the per-attempt timeout.\n', + '\n Timeout: LLM request timed out after 120s. Increase --timeout or omit it to disable the request timeout.\n', ); }); }); diff --git a/gitnexus/test/unit/wiki-llm-client.test.ts b/gitnexus/test/unit/wiki-llm-client.test.ts index 3107796cee..5b6a827c3e 100644 --- a/gitnexus/test/unit/wiki-llm-client.test.ts +++ b/gitnexus/test/unit/wiki-llm-client.test.ts @@ -310,7 +310,7 @@ describe('callLLM — timeout handling', () => { requestTimeoutMs: 120_000, }), ).rejects.toThrow( - 'LLM request timed out after 120s. Increase --timeout or omit it to disable the per-attempt timeout.', + 'LLM request timed out after 120s. Increase --timeout or omit it to disable the request timeout.', ); }); @@ -331,7 +331,7 @@ describe('callLLM — timeout handling', () => { requestTimeoutMs: 1_500, }), ).rejects.toThrow( - 'LLM request timed out after 1500ms. Increase --timeout or omit it to disable the per-attempt timeout.', + 'LLM request timed out after 1500ms. Increase --timeout or omit it to disable the request timeout.', ); }); @@ -352,9 +352,26 @@ describe('callLLM — timeout handling', () => { requestTimeoutMs: 120_000, }), ).rejects.toThrow( - 'LLM request timed out after 120s. Increase --timeout or omit it to disable the per-attempt timeout.', + 'LLM request timed out after 120s. Increase --timeout or omit it to disable the request timeout.', ); }); + + it('does not mislabel generic aborted connections as request timeouts', async () => { + const fetchSpy = vi.fn().mockRejectedValue(new Error('connection aborted by server')); + vi.stubGlobal('fetch', fetchSpy); + + const { callLLM } = await import('../../src/core/wiki/llm-client.js'); + await expect( + callLLM('test', { + apiKey: 'sk-test', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4o', + maxTokens: 500, + temperature: 0, + requestTimeoutMs: 120_000, + }), + ).rejects.toThrow('connection aborted by server'); + }); }); describe('callLLM — Azure content_filter error', () => {