From 2af4dc1b987d268a8502a0094907f3b1fc008810 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 15:24:54 +0000 Subject: [PATCH 01/19] fix: force IPv4-first DNS resolution to avoid WSL2 fetch timeouts WSL2's NAT frequently resolves api.anthropic.com to an IPv6 address that isn't routable from the guest, causing fetch() to hang until ETIMEDOUT on the larger payloads of the resolve and export steps (issue #19). Calling dns.setDefaultResultOrder('ipv4first') at module load applies the fix to both the CLI and the Next.js API routes without any user-facing configuration. The override is skipped if the user already set --dns-result-order via NODE_OPTIONS. Refs #19 --- lib/prompts.mjs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lib/prompts.mjs b/lib/prompts.mjs index 5566b99..80438d2 100644 --- a/lib/prompts.mjs +++ b/lib/prompts.mjs @@ -3,6 +3,16 @@ // and by the CLI in bin/mint-ds.mjs. Keep this file dependency-free so it runs // in plain Node without a build step. +import { setDefaultResultOrder } from 'node:dns' + +// WSL2's NAT often resolves api.anthropic.com to an IPv6 address that isn't +// routable, causing fetch() to hang until ETIMEDOUT (issue #19). Forcing IPv4 +// first avoids the hang on WSL2 and is a no-op on hosts where IPv6 works. +// Honors NODE_OPTIONS=--dns-result-order=... if the user already set it. +if (!process.env.NODE_OPTIONS?.includes('--dns-result-order')) { + setDefaultResultOrder('ipv4first') +} + const MODEL = 'claude-sonnet-4-20250514' const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages' From 99eaf57cedbbf4d9889d597a3e509cc74951817a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 15:52:05 +0000 Subject: [PATCH 02/19] fix: retry callAnthropic on transient network errors Even with ipv4first DNS, the first fetch under WSL2 occasionally fails with ETIMEDOUT before subsequent attempts succeed (observed on resolve). Retry up to twice with 500ms / 1500ms backoff, but only for network-level errors (ETIMEDOUT, ECONNRESET, undici socket/connect errors, etc.). HTTP responses are still surfaced as-is to avoid masking real API errors. Refs #19 --- lib/prompts.mjs | 68 ++++++++++++++++++++++++++++++++++++------------- 1 file changed, 50 insertions(+), 18 deletions(-) diff --git a/lib/prompts.mjs b/lib/prompts.mjs index 80438d2..85cd9d1 100644 --- a/lib/prompts.mjs +++ b/lib/prompts.mjs @@ -398,28 +398,60 @@ export function stripFences(raw) { .trim() } +// Network errors that benefit from a retry under WSL2's flaky NAT (issue #19). +// HTTP errors (4xx/5xx) are NOT retried — they're surfaced as-is. +const RETRYABLE_NET_CODES = new Set([ + 'ETIMEDOUT', 'ECONNRESET', 'ECONNREFUSED', 'EAI_AGAIN', + 'UND_ERR_SOCKET', 'UND_ERR_CONNECT_TIMEOUT', +]) + +function isRetryableNetworkError(err) { + const codes = [] + if (err?.code) codes.push(err.code) + if (err?.cause?.code) codes.push(err.cause.code) + for (const inner of err?.cause?.errors || []) { + if (inner?.code) codes.push(inner.code) + } + return codes.some((c) => RETRYABLE_NET_CODES.has(c)) +} + export async function callAnthropic({ apiKey, prompt, maxTokens = 3000 }) { if (!apiKey) { throw new Error('ANTHROPIC_API_KEY is required') } - const res = await fetch(ANTHROPIC_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': apiKey, - 'anthropic-version': '2023-06-01', - }, - body: JSON.stringify({ - model: MODEL, - max_tokens: maxTokens, - messages: [{ role: 'user', content: prompt }], - }), + const body = JSON.stringify({ + model: MODEL, + max_tokens: maxTokens, + messages: [{ role: 'user', content: prompt }], }) - const data = await res.json() - if (!res.ok) { - const msg = data?.error?.message || `Anthropic API error (${res.status})` - throw new Error(msg) + const backoffsMs = [500, 1500] + let lastErr + for (let attempt = 0; attempt <= backoffsMs.length; attempt++) { + try { + const res = await fetch(ANTHROPIC_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + body, + }) + const data = await res.json() + if (!res.ok) { + const msg = data?.error?.message || `Anthropic API error (${res.status})` + throw new Error(msg) + } + const block = (data.content || []).find((b) => b && b.type === 'text') + return block ? block.text : '' + } catch (err) { + lastErr = err + if (attempt < backoffsMs.length && isRetryableNetworkError(err)) { + await new Promise((r) => setTimeout(r, backoffsMs[attempt])) + continue + } + throw err + } } - const block = (data.content || []).find((b) => b && b.type === 'text') - return block ? block.text : '' + throw lastErr } From 97e30d0ed307a6549a1c95bfe21d924bb3aad9ad Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 16:04:56 +0000 Subject: [PATCH 03/19] chore: add MINT_DEBUG diagnostics for callAnthropic To narrow down the residual ETIMEDOUTs on WSL2 (issue #19), opt-in debug logging via MINT_DEBUG=1 prints: - DNS lookup result for api.anthropic.com (addresses + family) - Payload size and maxTokens per call - Per-attempt POST start, elapsed ms on failure, and decoded error codes (including undici cause chains: ETIMEDOUT, address, port, etc.) - Retry scheduling No behavior change when MINT_DEBUG is unset. Refs #19 --- lib/prompts.mjs | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/lib/prompts.mjs b/lib/prompts.mjs index 85cd9d1..3022660 100644 --- a/lib/prompts.mjs +++ b/lib/prompts.mjs @@ -3,7 +3,7 @@ // and by the CLI in bin/mint-ds.mjs. Keep this file dependency-free so it runs // in plain Node without a build step. -import { setDefaultResultOrder } from 'node:dns' +import { setDefaultResultOrder, lookup as dnsLookup } from 'node:dns' // WSL2's NAT often resolves api.anthropic.com to an IPv6 address that isn't // routable, causing fetch() to hang until ETIMEDOUT (issue #19). Forcing IPv4 @@ -15,6 +15,33 @@ if (!process.env.NODE_OPTIONS?.includes('--dns-result-order')) { const MODEL = 'claude-sonnet-4-20250514' const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages' +const ANTHROPIC_HOST = 'api.anthropic.com' +const DEBUG = process.env.MINT_DEBUG === '1' || process.env.MINT_DEBUG === 'true' + +function debugLog(...args) { + if (DEBUG) process.stderr.write('[mint:debug] ' + args.join(' ') + '\n') +} + +function dnsLookupAll(host) { + return new Promise((resolve) => { + dnsLookup(host, { all: true, verbatim: false }, (err, addresses) => { + if (err) resolve({ error: err.code || err.message }) + else resolve({ addresses }) + }) + }) +} + +function describeError(err) { + const codes = [] + if (err?.code) codes.push(err.code) + if (err?.cause?.code) codes.push(`cause:${err.cause.code}`) + for (const inner of err?.cause?.errors || []) { + if (inner?.code) codes.push(`inner:${inner.code}`) + if (inner?.address) codes.push(`addr:${inner.address}`) + if (inner?.port) codes.push(`port:${inner.port}`) + } + return codes.join(' ') || err?.message || String(err) +} export function preprocessCss(raw) { return String(raw) @@ -426,8 +453,15 @@ export async function callAnthropic({ apiKey, prompt, maxTokens = 3000 }) { }) const backoffsMs = [500, 1500] let lastErr + if (DEBUG) { + const dns = await dnsLookupAll(ANTHROPIC_HOST) + debugLog(`dns ${ANTHROPIC_HOST} ->`, JSON.stringify(dns)) + debugLog(`payload bytes=${Buffer.byteLength(body)} maxTokens=${maxTokens}`) + } for (let attempt = 0; attempt <= backoffsMs.length; attempt++) { + const startedAt = Date.now() try { + debugLog(`attempt ${attempt + 1}/${backoffsMs.length + 1} -> POST ${ANTHROPIC_URL}`) const res = await fetch(ANTHROPIC_URL, { method: 'POST', headers: { @@ -446,7 +480,10 @@ export async function callAnthropic({ apiKey, prompt, maxTokens = 3000 }) { return block ? block.text : '' } catch (err) { lastErr = err + const elapsed = Date.now() - startedAt + debugLog(`attempt ${attempt + 1} failed in ${elapsed}ms: ${describeError(err)}`) if (attempt < backoffsMs.length && isRetryableNetworkError(err)) { + debugLog(`retrying in ${backoffsMs[attempt]}ms`) await new Promise((r) => setTimeout(r, backoffsMs[attempt])) continue } From 4688676ab4b2d1f883c923e5e4ab4b3f0981ba7c Mon Sep 17 00:00:00 2001 From: Nadia Ujovich <48018975+nujovich@users.noreply.github.com> Date: Mon, 11 May 2026 15:26:02 +0200 Subject: [PATCH 04/19] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- lib/prompts.mjs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/lib/prompts.mjs b/lib/prompts.mjs index b3625d4..c2e1eb5 100644 --- a/lib/prompts.mjs +++ b/lib/prompts.mjs @@ -5,11 +5,23 @@ import { setDefaultResultOrder, lookup as dnsLookup } from 'node:dns' +function hasDnsResultOrderOverride() { + if (process.env.NODE_OPTIONS?.includes('--dns-result-order')) return true + + for (let i = 0; i < process.execArgv.length; i++) { + const arg = process.execArgv[i] + if (arg === '--dns-result-order') return true + if (arg?.startsWith('--dns-result-order=')) return true + } + + return false +} + // WSL2's NAT often resolves api.anthropic.com to an IPv6 address that isn't // routable, causing fetch() to hang until ETIMEDOUT (issue #19). Forcing IPv4 // first avoids the hang on WSL2 and is a no-op on hosts where IPv6 works. -// Honors NODE_OPTIONS=--dns-result-order=... if the user already set it. -if (!process.env.NODE_OPTIONS?.includes('--dns-result-order')) { +// Honors user-provided --dns-result-order from NODE_OPTIONS or CLI args. +if (!hasDnsResultOrderOverride()) { setDefaultResultOrder('ipv4first') } From b3af58671653e125b9289834c6dba243a16438e6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 13:42:55 +0000 Subject: [PATCH 05/19] fix: wire debug diagnostics and retry logic into callAnthropic Agent-Logs-Url: https://github.com/nujovich/mint/sessions/99b96029-e555-459d-a11c-a1bd6069b190 --- lib/prompts.mjs | 61 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 42 insertions(+), 19 deletions(-) diff --git a/lib/prompts.mjs b/lib/prompts.mjs index c2e1eb5..2f5b175 100644 --- a/lib/prompts.mjs +++ b/lib/prompts.mjs @@ -458,25 +458,48 @@ export async function callAnthropic({ apiKey, prompt, maxTokens = 3000 }) { if (!apiKey) { throw new Error('ANTHROPIC_API_KEY is required') } - const res = await fetch(ANTHROPIC_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': apiKey, - 'anthropic-version': '2023-06-01', - }, - body: JSON.stringify({ - model: MODEL, - max_tokens: maxTokens, - messages: [{ role: 'user', content: prompt }], - }), + const body = JSON.stringify({ + model: MODEL, + max_tokens: maxTokens, + messages: [{ role: 'user', content: prompt }], }) - const data = await res.json() - if (!res.ok) { - const msg = data?.error?.message || `Anthropic API error (${res.status})` - throw new Error(msg) + const backoffsMs = [500, 1500] + /* v8 ignore start */ + if (DEBUG) { + const dns = await dnsLookupAll(ANTHROPIC_HOST) + debugLog(`dns ${ANTHROPIC_HOST} ->`, JSON.stringify(dns)) + debugLog(`payload bytes=${Buffer.byteLength(body)} maxTokens=${maxTokens}`) } - const block = (data.content || []).find((b) => b && b.type === 'text') - return block ? block.text : '' + for (let attempt = 0; attempt <= backoffsMs.length; attempt++) { + const startedAt = Date.now() + try { + debugLog(`attempt ${attempt + 1}/${backoffsMs.length + 1} -> POST ${ANTHROPIC_URL}`) + const res = await fetch(ANTHROPIC_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + body, + }) + const data = await res.json() + if (!res.ok) { + const msg = data?.error?.message || `Anthropic API error (${res.status})` + throw new Error(msg) + } + const block = (data.content || []).find((b) => b && b.type === 'text') + return block ? block.text : '' + } catch (err) { + const elapsed = Date.now() - startedAt + debugLog(`attempt ${attempt + 1} failed in ${elapsed}ms: ${describeError(err)}`) + if (attempt < backoffsMs.length && isRetryableNetworkError(err)) { + debugLog(`retrying in ${backoffsMs[attempt]}ms`) + await new Promise((r) => setTimeout(r, backoffsMs[attempt])) + continue + } + throw err + } + } + /* v8 ignore end */ } -/* v8 ignore end */ From a1264031c947339721da8e83b08e7830da6363eb Mon Sep 17 00:00:00 2001 From: Nadia Ujovich <48018975+nujovich@users.noreply.github.com> Date: Mon, 11 May 2026 16:24:31 +0200 Subject: [PATCH 06/19] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- lib/prompts.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/prompts.mjs b/lib/prompts.mjs index 2f5b175..fd1c494 100644 --- a/lib/prompts.mjs +++ b/lib/prompts.mjs @@ -492,7 +492,9 @@ export async function callAnthropic({ apiKey, prompt, maxTokens = 3000 }) { return block ? block.text : '' } catch (err) { const elapsed = Date.now() - startedAt - debugLog(`attempt ${attempt + 1} failed in ${elapsed}ms: ${describeError(err)}`) + if (DEBUG) { + debugLog(`attempt ${attempt + 1} failed in ${elapsed}ms: ${describeError(err)}`) + } if (attempt < backoffsMs.length && isRetryableNetworkError(err)) { debugLog(`retrying in ${backoffsMs[attempt]}ms`) await new Promise((r) => setTimeout(r, backoffsMs[attempt])) From f537abc8e356a2e935db9c0dd2d52777901eb69f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 14:35:41 +0000 Subject: [PATCH 07/19] fix: narrow setDefaultResultOrder('ipv4first') to WSL2 only Agent-Logs-Url: https://github.com/nujovich/mint/sessions/eaee4465-8cd2-4d9e-94b4-271423f172d5 --- lib/prompts.mjs | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/lib/prompts.mjs b/lib/prompts.mjs index fd1c494..6776162 100644 --- a/lib/prompts.mjs +++ b/lib/prompts.mjs @@ -4,6 +4,7 @@ // in plain Node without a build step. import { setDefaultResultOrder, lookup as dnsLookup } from 'node:dns' +import { readFileSync } from 'node:fs' function hasDnsResultOrderOverride() { if (process.env.NODE_OPTIONS?.includes('--dns-result-order')) return true @@ -17,11 +18,24 @@ function hasDnsResultOrderOverride() { return false } -// WSL2's NAT often resolves api.anthropic.com to an IPv6 address that isn't -// routable, causing fetch() to hang until ETIMEDOUT (issue #19). Forcing IPv4 -// first avoids the hang on WSL2 and is a no-op on hosts where IPv6 works. -// Honors user-provided --dns-result-order from NODE_OPTIONS or CLI args. -if (!hasDnsResultOrderOverride()) { +// Returns true when running inside WSL2 (Windows Subsystem for Linux v2). +// WSL2 always sets WSL_DISTRO_NAME; WSL_INTEROP is a fallback for edge cases. +// /proc/version contains "microsoft" on WSL2 kernels as a belt-and-suspenders +// check if neither env var is present. +function isWSL2() { + if (process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP) return true + try { + return /microsoft/i.test(readFileSync('/proc/version', 'utf8')) + } catch { + return false + } +} + +// On WSL2, NAT routing can resolve api.anthropic.com to an IPv6 address that +// isn't reachable, causing fetch() to hang until ETIMEDOUT (issue #19). +// Force IPv4-first only on WSL2 so that non-WSL hosts keep their default +// (verbatim) ordering. Honors any user-provided --dns-result-order override. +if (isWSL2() && !hasDnsResultOrderOverride()) { setDefaultResultOrder('ipv4first') } From 041bf1b54a7035f6ea31cda82b6dd7f6d45564fd Mon Sep 17 00:00:00 2001 From: Nadia Ujovich Date: Mon, 11 May 2026 17:16:21 +0200 Subject: [PATCH 08/19] chore: exclude network/debug helpers from v8 coverage --- lib/prompts.mjs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/prompts.mjs b/lib/prompts.mjs index 6776162..242ec3d 100644 --- a/lib/prompts.mjs +++ b/lib/prompts.mjs @@ -44,10 +44,12 @@ const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages' const ANTHROPIC_HOST = 'api.anthropic.com' const DEBUG = process.env.MINT_DEBUG === '1' || process.env.MINT_DEBUG === 'true' +/* v8 ignore next 3 */ function debugLog(...args) { if (DEBUG) process.stderr.write('[mint:debug] ' + args.join(' ') + '\n') } +/* v8 ignore start */ function dnsLookupAll(host) { return new Promise((resolve) => { dnsLookup(host, { all: true, verbatim: false }, (err, addresses) => { @@ -56,7 +58,9 @@ function dnsLookupAll(host) { }) }) } +/* v8 ignore end */ +/* v8 ignore start */ function describeError(err) { const codes = [] if (err?.code) codes.push(err.code) @@ -68,6 +72,7 @@ function describeError(err) { } return codes.join(' ') || err?.message || String(err) } +/* v8 ignore end */ export function preprocessCss(raw) { return String(raw) @@ -458,6 +463,7 @@ const RETRYABLE_NET_CODES = new Set([ 'UND_ERR_SOCKET', 'UND_ERR_CONNECT_TIMEOUT', ]) +/* v8 ignore start */ function isRetryableNetworkError(err) { const codes = [] if (err?.code) codes.push(err.code) @@ -467,6 +473,7 @@ function isRetryableNetworkError(err) { } return codes.some((c) => RETRYABLE_NET_CODES.has(c)) } +/* v8 ignore end */ export async function callAnthropic({ apiKey, prompt, maxTokens = 3000 }) { if (!apiKey) { From a5a6c40cfb5c97029aa48303123e0a955b1000e1 Mon Sep 17 00:00:00 2001 From: Nadia Ujovich Date: Mon, 11 May 2026 17:18:14 +0200 Subject: [PATCH 09/19] feat: add callAnthropic import and vi/afterEach to test skeleton - Updated vitest import to include vi and afterEach for test setup/teardown - Added callAnthropic to the prompts.mjs imports - All 35 existing tests pass This prepares for Task 3: Adding callAnthropic tests --- lib/__tests__/prompts.test.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/__tests__/prompts.test.mjs b/lib/__tests__/prompts.test.mjs index ae05284..2b1b3da 100644 --- a/lib/__tests__/prompts.test.mjs +++ b/lib/__tests__/prompts.test.mjs @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi, afterEach } from 'vitest' import { stripFences, resolveTarget, @@ -6,6 +6,7 @@ import { buildAuditPrompt, buildResolvePrompt, buildExportPrompt, + callAnthropic, } from '../prompts.mjs' describe('stripFences', () => { From 7261b4ab654e0ca6517f0243784f8fb8bf46cb13 Mon Sep 17 00:00:00 2001 From: Nadia Ujovich Date: Mon, 11 May 2026 17:20:33 +0200 Subject: [PATCH 10/19] test: add callAnthropic unit tests to close coverage gaps --- lib/__tests__/prompts.test.mjs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/lib/__tests__/prompts.test.mjs b/lib/__tests__/prompts.test.mjs index 2b1b3da..e745367 100644 --- a/lib/__tests__/prompts.test.mjs +++ b/lib/__tests__/prompts.test.mjs @@ -204,3 +204,24 @@ describe('buildExportPrompt', () => { expect(result.length).toBeGreaterThan(0) }) }) + +describe('callAnthropic', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('throws when apiKey is missing', async () => { + await expect(callAnthropic({ prompt: 'hello' })).rejects.toThrow( + 'ANTHROPIC_API_KEY is required', + ) + }) + + it('returns the text content from a successful response', async () => { + vi.stubGlobal('fetch', async () => ({ + ok: true, + json: async () => ({ content: [{ type: 'text', text: 'ok' }] }), + })) + const result = await callAnthropic({ apiKey: 'test-key', prompt: 'hello' }) + expect(result).toBe('ok') + }) +}) From c66fc1b4a67048109181dae35f980638b7662fe9 Mon Sep 17 00:00:00 2001 From: Nadia Ujovich Date: Mon, 11 May 2026 18:51:12 +0200 Subject: [PATCH 11/19] feat: export isWSL2 and hasDnsResultOrderOverride for unit testing --- lib/prompts.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/prompts.mjs b/lib/prompts.mjs index 242ec3d..a60fb50 100644 --- a/lib/prompts.mjs +++ b/lib/prompts.mjs @@ -6,7 +6,7 @@ import { setDefaultResultOrder, lookup as dnsLookup } from 'node:dns' import { readFileSync } from 'node:fs' -function hasDnsResultOrderOverride() { +export function hasDnsResultOrderOverride() { if (process.env.NODE_OPTIONS?.includes('--dns-result-order')) return true for (let i = 0; i < process.execArgv.length; i++) { @@ -22,7 +22,7 @@ function hasDnsResultOrderOverride() { // WSL2 always sets WSL_DISTRO_NAME; WSL_INTEROP is a fallback for edge cases. // /proc/version contains "microsoft" on WSL2 kernels as a belt-and-suspenders // check if neither env var is present. -function isWSL2() { +export function isWSL2() { if (process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP) return true try { return /microsoft/i.test(readFileSync('/proc/version', 'utf8')) @@ -36,6 +36,7 @@ function isWSL2() { // Force IPv4-first only on WSL2 so that non-WSL hosts keep their default // (verbatim) ordering. Honors any user-provided --dns-result-order override. if (isWSL2() && !hasDnsResultOrderOverride()) { + /* v8 ignore next */ setDefaultResultOrder('ipv4first') } From f85017d50c465223fab85de88e1eb798128d86a6 Mon Sep 17 00:00:00 2001 From: Nadia Ujovich Date: Mon, 11 May 2026 18:53:12 +0200 Subject: [PATCH 12/19] test: add node:fs mock and new imports for WSL2 detection tests --- lib/__tests__/prompts.test.mjs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/__tests__/prompts.test.mjs b/lib/__tests__/prompts.test.mjs index e745367..1d3f884 100644 --- a/lib/__tests__/prompts.test.mjs +++ b/lib/__tests__/prompts.test.mjs @@ -1,4 +1,5 @@ -import { describe, it, expect, vi, afterEach } from 'vitest' +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest' +import { readFileSync } from 'node:fs' import { stripFences, resolveTarget, @@ -7,8 +8,14 @@ import { buildResolvePrompt, buildExportPrompt, callAnthropic, + hasDnsResultOrderOverride, + isWSL2, } from '../prompts.mjs' +vi.mock('node:fs', () => ({ + readFileSync: vi.fn(), +})) + describe('stripFences', () => { it('strips a js-fenced code block', () => { expect(stripFences('```js\nconst x = 1\n```')).toBe('const x = 1') From ecf1df8c14c6dde878d73f1e8c3c93253f65d732 Mon Sep 17 00:00:00 2001 From: Nadia Ujovich Date: Mon, 11 May 2026 18:55:22 +0200 Subject: [PATCH 13/19] test: add hasDnsResultOrderOverride unit tests --- lib/__tests__/prompts.test.mjs | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/lib/__tests__/prompts.test.mjs b/lib/__tests__/prompts.test.mjs index 1d3f884..da959f3 100644 --- a/lib/__tests__/prompts.test.mjs +++ b/lib/__tests__/prompts.test.mjs @@ -232,3 +232,37 @@ describe('callAnthropic', () => { expect(result).toBe('ok') }) }) + +describe('hasDnsResultOrderOverride', () => { + let originalExecArgv + + beforeEach(() => { + originalExecArgv = process.execArgv + process.execArgv = [] + vi.stubEnv('NODE_OPTIONS', '') + }) + + afterEach(() => { + process.execArgv = originalExecArgv + vi.unstubAllEnvs() + }) + + it('returns true when NODE_OPTIONS includes --dns-result-order', () => { + vi.stubEnv('NODE_OPTIONS', '--dns-result-order=verbatim') + expect(hasDnsResultOrderOverride()).toBe(true) + }) + + it('returns true when execArgv contains --dns-result-order', () => { + process.execArgv = ['--dns-result-order'] + expect(hasDnsResultOrderOverride()).toBe(true) + }) + + it('returns true when execArgv contains --dns-result-order=value', () => { + process.execArgv = ['--dns-result-order=verbatim'] + expect(hasDnsResultOrderOverride()).toBe(true) + }) + + it('returns false when neither NODE_OPTIONS nor execArgv signal an override', () => { + expect(hasDnsResultOrderOverride()).toBe(false) + }) +}) From 6147513baeff2953109d4c75e97f04e9aa92113d Mon Sep 17 00:00:00 2001 From: Nadia Ujovich Date: Mon, 11 May 2026 18:57:52 +0200 Subject: [PATCH 14/19] test: add isWSL2 unit tests --- lib/__tests__/prompts.test.mjs | 39 ++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/lib/__tests__/prompts.test.mjs b/lib/__tests__/prompts.test.mjs index da959f3..b018447 100644 --- a/lib/__tests__/prompts.test.mjs +++ b/lib/__tests__/prompts.test.mjs @@ -266,3 +266,42 @@ describe('hasDnsResultOrderOverride', () => { expect(hasDnsResultOrderOverride()).toBe(false) }) }) + +describe('isWSL2', () => { + afterEach(() => { + vi.unstubAllEnvs() + vi.mocked(readFileSync).mockReset() + }) + + it('returns true when WSL_DISTRO_NAME is set', () => { + vi.stubEnv('WSL_DISTRO_NAME', 'Ubuntu') + expect(isWSL2()).toBe(true) + }) + + it('returns true when WSL_INTEROP is set', () => { + vi.stubEnv('WSL_DISTRO_NAME', '') + vi.stubEnv('WSL_INTEROP', '/run/interop') + expect(isWSL2()).toBe(true) + }) + + it('returns true when /proc/version contains microsoft', () => { + vi.stubEnv('WSL_DISTRO_NAME', '') + vi.stubEnv('WSL_INTEROP', '') + vi.mocked(readFileSync).mockReturnValue('Linux microsoft version') + expect(isWSL2()).toBe(true) + }) + + it('returns false when /proc/version has no microsoft', () => { + vi.stubEnv('WSL_DISTRO_NAME', '') + vi.stubEnv('WSL_INTEROP', '') + vi.mocked(readFileSync).mockReturnValue('Linux 5.15.0 Ubuntu') + expect(isWSL2()).toBe(false) + }) + + it('returns false when readFileSync throws', () => { + vi.stubEnv('WSL_DISTRO_NAME', '') + vi.stubEnv('WSL_INTEROP', '') + vi.mocked(readFileSync).mockImplementation(() => { throw new Error('ENOENT') }) + expect(isWSL2()).toBe(false) + }) +}) From bc3b766e169893e6547c6149608c94af91389ceb Mon Sep 17 00:00:00 2001 From: Nadia Ujovich <48018975+nujovich@users.noreply.github.com> Date: Mon, 11 May 2026 19:25:56 +0200 Subject: [PATCH 15/19] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- lib/prompts.mjs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/lib/prompts.mjs b/lib/prompts.mjs index a60fb50..8548b05 100644 --- a/lib/prompts.mjs +++ b/lib/prompts.mjs @@ -18,14 +18,17 @@ export function hasDnsResultOrderOverride() { return false } -// Returns true when running inside WSL2 (Windows Subsystem for Linux v2). -// WSL2 always sets WSL_DISTRO_NAME; WSL_INTEROP is a fallback for edge cases. -// /proc/version contains "microsoft" on WSL2 kernels as a belt-and-suspenders -// check if neither env var is present. +// Returns true only when running inside WSL2 (Windows Subsystem for Linux v2). +// Generic WSL env vars such as WSL_DISTRO_NAME / WSL_INTEROP are not enough to +// distinguish WSL1 from WSL2, so check kernel metadata for a WSL2-specific +// marker instead. export function isWSL2() { - if (process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP) return true try { - return /microsoft/i.test(readFileSync('/proc/version', 'utf8')) + if (/wsl2/i.test(readFileSync('/proc/sys/kernel/osrelease', 'utf8'))) return true + } catch {} + + try { + return /microsoft.*wsl2|wsl2.*microsoft/i.test(readFileSync('/proc/version', 'utf8')) } catch { return false } From c49aa247facb2e2789f31cc0eb9fb3244e48a46f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 17:28:41 +0000 Subject: [PATCH 16/19] refactor: move hasDnsResultOrderOverride and isWSL2 to internal lib/net-utils.mjs Agent-Logs-Url: https://github.com/nujovich/mint/sessions/42602eac-c540-4ed8-b7a6-bb49822c56f3 --- lib/__tests__/net-utils.test.mjs | 92 ++++++++++++++++++++++++++++++++ lib/__tests__/prompts.test.mjs | 82 +--------------------------- lib/net-utils.mjs | 33 ++++++++++++ lib/prompts.mjs | 30 +---------- 4 files changed, 127 insertions(+), 110 deletions(-) create mode 100644 lib/__tests__/net-utils.test.mjs create mode 100644 lib/net-utils.mjs diff --git a/lib/__tests__/net-utils.test.mjs b/lib/__tests__/net-utils.test.mjs new file mode 100644 index 0000000..07f15b0 --- /dev/null +++ b/lib/__tests__/net-utils.test.mjs @@ -0,0 +1,92 @@ +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest' +import { readFileSync } from 'node:fs' +import { hasDnsResultOrderOverride, isWSL2 } from '../net-utils.mjs' + +vi.mock('node:fs', () => ({ + readFileSync: vi.fn(), +})) + +describe('hasDnsResultOrderOverride', () => { + let originalExecArgv + + beforeEach(() => { + originalExecArgv = process.execArgv + process.execArgv = [] + vi.stubEnv('NODE_OPTIONS', '') + }) + + afterEach(() => { + process.execArgv = originalExecArgv + vi.unstubAllEnvs() + }) + + it('returns true when NODE_OPTIONS includes --dns-result-order', () => { + vi.stubEnv('NODE_OPTIONS', '--dns-result-order=verbatim') + expect(hasDnsResultOrderOverride()).toBe(true) + }) + + it('returns true when execArgv contains --dns-result-order', () => { + process.execArgv = ['--dns-result-order'] + expect(hasDnsResultOrderOverride()).toBe(true) + }) + + it('returns true when execArgv contains --dns-result-order=value', () => { + process.execArgv = ['--dns-result-order=verbatim'] + expect(hasDnsResultOrderOverride()).toBe(true) + }) + + it('returns false when neither NODE_OPTIONS nor execArgv signal an override', () => { + expect(hasDnsResultOrderOverride()).toBe(false) + }) +}) + +describe('isWSL2', () => { + afterEach(() => { + vi.mocked(readFileSync).mockReset() + }) + + it('returns true when /proc/sys/kernel/osrelease contains wsl2', () => { + vi.mocked(readFileSync).mockImplementation((path) => { + if (String(path).includes('osrelease')) return '5.15.153.1-microsoft-standard-WSL2' + throw new Error('ENOENT') + }) + expect(isWSL2()).toBe(true) + }) + + it('returns true when /proc/version matches microsoft.*wsl2 pattern', () => { + vi.mocked(readFileSync).mockImplementation((path) => { + if (String(path).includes('osrelease')) throw new Error('ENOENT') + return 'Linux version 5.15.0-microsoft-standard-WSL2 (gcc)' + }) + expect(isWSL2()).toBe(true) + }) + + it('returns true when /proc/version matches wsl2.*microsoft pattern', () => { + vi.mocked(readFileSync).mockImplementation((path) => { + if (String(path).includes('osrelease')) throw new Error('ENOENT') + return 'Linux WSL2-microsoft kernel' + }) + expect(isWSL2()).toBe(true) + }) + + it('returns false when /proc/version contains microsoft but not wsl2', () => { + vi.mocked(readFileSync).mockImplementation((path) => { + if (String(path).includes('osrelease')) throw new Error('ENOENT') + return 'Linux microsoft version' + }) + expect(isWSL2()).toBe(false) + }) + + it('returns false when /proc/version has no microsoft or wsl2', () => { + vi.mocked(readFileSync).mockImplementation((path) => { + if (String(path).includes('osrelease')) throw new Error('ENOENT') + return 'Linux 5.15.0 Ubuntu' + }) + expect(isWSL2()).toBe(false) + }) + + it('returns false when both reads throw', () => { + vi.mocked(readFileSync).mockImplementation(() => { throw new Error('ENOENT') }) + expect(isWSL2()).toBe(false) + }) +}) diff --git a/lib/__tests__/prompts.test.mjs b/lib/__tests__/prompts.test.mjs index b018447..e745367 100644 --- a/lib/__tests__/prompts.test.mjs +++ b/lib/__tests__/prompts.test.mjs @@ -1,5 +1,4 @@ -import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest' -import { readFileSync } from 'node:fs' +import { describe, it, expect, vi, afterEach } from 'vitest' import { stripFences, resolveTarget, @@ -8,14 +7,8 @@ import { buildResolvePrompt, buildExportPrompt, callAnthropic, - hasDnsResultOrderOverride, - isWSL2, } from '../prompts.mjs' -vi.mock('node:fs', () => ({ - readFileSync: vi.fn(), -})) - describe('stripFences', () => { it('strips a js-fenced code block', () => { expect(stripFences('```js\nconst x = 1\n```')).toBe('const x = 1') @@ -232,76 +225,3 @@ describe('callAnthropic', () => { expect(result).toBe('ok') }) }) - -describe('hasDnsResultOrderOverride', () => { - let originalExecArgv - - beforeEach(() => { - originalExecArgv = process.execArgv - process.execArgv = [] - vi.stubEnv('NODE_OPTIONS', '') - }) - - afterEach(() => { - process.execArgv = originalExecArgv - vi.unstubAllEnvs() - }) - - it('returns true when NODE_OPTIONS includes --dns-result-order', () => { - vi.stubEnv('NODE_OPTIONS', '--dns-result-order=verbatim') - expect(hasDnsResultOrderOverride()).toBe(true) - }) - - it('returns true when execArgv contains --dns-result-order', () => { - process.execArgv = ['--dns-result-order'] - expect(hasDnsResultOrderOverride()).toBe(true) - }) - - it('returns true when execArgv contains --dns-result-order=value', () => { - process.execArgv = ['--dns-result-order=verbatim'] - expect(hasDnsResultOrderOverride()).toBe(true) - }) - - it('returns false when neither NODE_OPTIONS nor execArgv signal an override', () => { - expect(hasDnsResultOrderOverride()).toBe(false) - }) -}) - -describe('isWSL2', () => { - afterEach(() => { - vi.unstubAllEnvs() - vi.mocked(readFileSync).mockReset() - }) - - it('returns true when WSL_DISTRO_NAME is set', () => { - vi.stubEnv('WSL_DISTRO_NAME', 'Ubuntu') - expect(isWSL2()).toBe(true) - }) - - it('returns true when WSL_INTEROP is set', () => { - vi.stubEnv('WSL_DISTRO_NAME', '') - vi.stubEnv('WSL_INTEROP', '/run/interop') - expect(isWSL2()).toBe(true) - }) - - it('returns true when /proc/version contains microsoft', () => { - vi.stubEnv('WSL_DISTRO_NAME', '') - vi.stubEnv('WSL_INTEROP', '') - vi.mocked(readFileSync).mockReturnValue('Linux microsoft version') - expect(isWSL2()).toBe(true) - }) - - it('returns false when /proc/version has no microsoft', () => { - vi.stubEnv('WSL_DISTRO_NAME', '') - vi.stubEnv('WSL_INTEROP', '') - vi.mocked(readFileSync).mockReturnValue('Linux 5.15.0 Ubuntu') - expect(isWSL2()).toBe(false) - }) - - it('returns false when readFileSync throws', () => { - vi.stubEnv('WSL_DISTRO_NAME', '') - vi.stubEnv('WSL_INTEROP', '') - vi.mocked(readFileSync).mockImplementation(() => { throw new Error('ENOENT') }) - expect(isWSL2()).toBe(false) - }) -}) diff --git a/lib/net-utils.mjs b/lib/net-utils.mjs new file mode 100644 index 0000000..c65ac32 --- /dev/null +++ b/lib/net-utils.mjs @@ -0,0 +1,33 @@ +// Internal network/environment utilities for lib/prompts.mjs. +// Not part of the published package surface — import directly from this +// module in tests rather than going through prompts.mjs. + +import { readFileSync } from 'node:fs' + +export function hasDnsResultOrderOverride() { + if (process.env.NODE_OPTIONS?.includes('--dns-result-order')) return true + + for (let i = 0; i < process.execArgv.length; i++) { + const arg = process.execArgv[i] + if (arg === '--dns-result-order') return true + if (arg?.startsWith('--dns-result-order=')) return true + } + + return false +} + +// Returns true only when running inside WSL2 (Windows Subsystem for Linux v2). +// Generic WSL env vars such as WSL_DISTRO_NAME / WSL_INTEROP are not enough to +// distinguish WSL1 from WSL2, so check kernel metadata for a WSL2-specific +// marker instead. +export function isWSL2() { + try { + if (/wsl2/i.test(readFileSync('/proc/sys/kernel/osrelease', 'utf8'))) return true + } catch {} + + try { + return /microsoft.*wsl2|wsl2.*microsoft/i.test(readFileSync('/proc/version', 'utf8')) + } catch { + return false + } +} diff --git a/lib/prompts.mjs b/lib/prompts.mjs index 8548b05..ba57826 100644 --- a/lib/prompts.mjs +++ b/lib/prompts.mjs @@ -4,35 +4,7 @@ // in plain Node without a build step. import { setDefaultResultOrder, lookup as dnsLookup } from 'node:dns' -import { readFileSync } from 'node:fs' - -export function hasDnsResultOrderOverride() { - if (process.env.NODE_OPTIONS?.includes('--dns-result-order')) return true - - for (let i = 0; i < process.execArgv.length; i++) { - const arg = process.execArgv[i] - if (arg === '--dns-result-order') return true - if (arg?.startsWith('--dns-result-order=')) return true - } - - return false -} - -// Returns true only when running inside WSL2 (Windows Subsystem for Linux v2). -// Generic WSL env vars such as WSL_DISTRO_NAME / WSL_INTEROP are not enough to -// distinguish WSL1 from WSL2, so check kernel metadata for a WSL2-specific -// marker instead. -export function isWSL2() { - try { - if (/wsl2/i.test(readFileSync('/proc/sys/kernel/osrelease', 'utf8'))) return true - } catch {} - - try { - return /microsoft.*wsl2|wsl2.*microsoft/i.test(readFileSync('/proc/version', 'utf8')) - } catch { - return false - } -} +import { hasDnsResultOrderOverride, isWSL2 } from './net-utils.mjs' // On WSL2, NAT routing can resolve api.anthropic.com to an IPv6 address that // isn't reachable, causing fetch() to hang until ETIMEDOUT (issue #19). From 5b3fe10810b4c8b58c44df33cfc2ca1c7d9235bd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 17:36:46 +0000 Subject: [PATCH 17/19] test: add retry/backoff tests and narrow v8 ignore regions in callAnthropic Agent-Logs-Url: https://github.com/nujovich/mint/sessions/adbc2709-f51d-44ad-9ddc-740aeb4045db --- lib/__tests__/prompts.test.mjs | 97 +++++++++++++++++++++++++++++++++- lib/prompts.mjs | 6 +-- 2 files changed, 98 insertions(+), 5 deletions(-) diff --git a/lib/__tests__/prompts.test.mjs b/lib/__tests__/prompts.test.mjs index e745367..cb7c17b 100644 --- a/lib/__tests__/prompts.test.mjs +++ b/lib/__tests__/prompts.test.mjs @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, afterEach } from 'vitest' +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest' import { stripFences, resolveTarget, @@ -208,6 +208,7 @@ describe('buildExportPrompt', () => { describe('callAnthropic', () => { afterEach(() => { vi.unstubAllGlobals() + vi.useRealTimers() }) it('throws when apiKey is missing', async () => { @@ -224,4 +225,98 @@ describe('callAnthropic', () => { const result = await callAnthropic({ apiKey: 'test-key', prompt: 'hello' }) expect(result).toBe('ok') }) + + it('throws on HTTP error response without retrying', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + json: async () => ({ error: { message: 'Internal Server Error' } }), + }) + vi.stubGlobal('fetch', fetchMock) + await expect(callAnthropic({ apiKey: 'test-key', prompt: 'hello' })).rejects.toThrow( + 'Internal Server Error', + ) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('throws on non-retryable network error without retrying', async () => { + const nonRetryable = Object.assign(new Error('DNS lookup failed'), { code: 'ENOTFOUND' }) + const fetchMock = vi.fn().mockRejectedValue(nonRetryable) + vi.stubGlobal('fetch', fetchMock) + await expect(callAnthropic({ apiKey: 'test-key', prompt: 'hello' })).rejects.toThrow( + 'DNS lookup failed', + ) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + describe('retry behavior', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + it('retries on ETIMEDOUT and succeeds on second attempt', async () => { + const fetchMock = vi.fn() + .mockRejectedValueOnce(Object.assign(new Error('etimedout'), { code: 'ETIMEDOUT' })) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ content: [{ type: 'text', text: 'retried-ok' }] }), + }) + vi.stubGlobal('fetch', fetchMock) + + const promise = callAnthropic({ apiKey: 'test-key', prompt: 'hello' }) + await vi.advanceTimersByTimeAsync(500) + await expect(promise).resolves.toBe('retried-ok') + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it('retries on ECONNRESET (via cause.code) and succeeds on second attempt', async () => { + const wrapped = Object.assign(new Error('socket error'), { + cause: { code: 'ECONNRESET' }, + }) + const fetchMock = vi.fn() + .mockRejectedValueOnce(wrapped) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ content: [{ type: 'text', text: 'recovered' }] }), + }) + vi.stubGlobal('fetch', fetchMock) + + const promise = callAnthropic({ apiKey: 'test-key', prompt: 'hello' }) + await vi.advanceTimersByTimeAsync(500) + await expect(promise).resolves.toBe('recovered') + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it('retries on undici aggregate error (cause.errors[].code) and succeeds', async () => { + const aggregate = Object.assign(new Error('undici error'), { + cause: { errors: [{ code: 'UND_ERR_SOCKET' }] }, + }) + const fetchMock = vi.fn() + .mockRejectedValueOnce(aggregate) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ content: [{ type: 'text', text: 'undici-ok' }] }), + }) + vi.stubGlobal('fetch', fetchMock) + + const promise = callAnthropic({ apiKey: 'test-key', prompt: 'hello' }) + await vi.advanceTimersByTimeAsync(500) + await expect(promise).resolves.toBe('undici-ok') + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it('throws after exhausting all retries', async () => { + const err = Object.assign(new Error('timed out'), { code: 'ETIMEDOUT' }) + const fetchMock = vi.fn().mockRejectedValue(err) + vi.stubGlobal('fetch', fetchMock) + + const promise = callAnthropic({ apiKey: 'test-key', prompt: 'hello' }) + // Register the rejection handler before advancing timers to avoid an + // unhandled-rejection warning while the timers are firing. + const assertion = expect(promise).rejects.toThrow('timed out') + await vi.advanceTimersByTimeAsync(2000) + await assertion + expect(fetchMock).toHaveBeenCalledTimes(3) + }) + }) }) diff --git a/lib/prompts.mjs b/lib/prompts.mjs index ba57826..eedafcf 100644 --- a/lib/prompts.mjs +++ b/lib/prompts.mjs @@ -439,7 +439,6 @@ const RETRYABLE_NET_CODES = new Set([ 'UND_ERR_SOCKET', 'UND_ERR_CONNECT_TIMEOUT', ]) -/* v8 ignore start */ function isRetryableNetworkError(err) { const codes = [] if (err?.code) codes.push(err.code) @@ -449,7 +448,6 @@ function isRetryableNetworkError(err) { } return codes.some((c) => RETRYABLE_NET_CODES.has(c)) } -/* v8 ignore end */ export async function callAnthropic({ apiKey, prompt, maxTokens = 3000 }) { if (!apiKey) { @@ -461,7 +459,7 @@ export async function callAnthropic({ apiKey, prompt, maxTokens = 3000 }) { messages: [{ role: 'user', content: prompt }], }) const backoffsMs = [500, 1500] - /* v8 ignore start */ + /* v8 ignore next 5 */ if (DEBUG) { const dns = await dnsLookupAll(ANTHROPIC_HOST) debugLog(`dns ${ANTHROPIC_HOST} ->`, JSON.stringify(dns)) @@ -489,6 +487,7 @@ export async function callAnthropic({ apiKey, prompt, maxTokens = 3000 }) { return block ? block.text : '' } catch (err) { const elapsed = Date.now() - startedAt + /* v8 ignore next 3 */ if (DEBUG) { debugLog(`attempt ${attempt + 1} failed in ${elapsed}ms: ${describeError(err)}`) } @@ -500,5 +499,4 @@ export async function callAnthropic({ apiKey, prompt, maxTokens = 3000 }) { throw err } } - /* v8 ignore end */ } From c2851e67e595d3e34ea03adff242bc06820034c8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 17:37:28 +0000 Subject: [PATCH 18/19] refactor: rename nonRetryable to nonRetryableError for clarity Agent-Logs-Url: https://github.com/nujovich/mint/sessions/adbc2709-f51d-44ad-9ddc-740aeb4045db --- lib/__tests__/prompts.test.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/__tests__/prompts.test.mjs b/lib/__tests__/prompts.test.mjs index cb7c17b..02e759b 100644 --- a/lib/__tests__/prompts.test.mjs +++ b/lib/__tests__/prompts.test.mjs @@ -240,8 +240,8 @@ describe('callAnthropic', () => { }) it('throws on non-retryable network error without retrying', async () => { - const nonRetryable = Object.assign(new Error('DNS lookup failed'), { code: 'ENOTFOUND' }) - const fetchMock = vi.fn().mockRejectedValue(nonRetryable) + const nonRetryableError = Object.assign(new Error('DNS lookup failed'), { code: 'ENOTFOUND' }) + const fetchMock = vi.fn().mockRejectedValue(nonRetryableError) vi.stubGlobal('fetch', fetchMock) await expect(callAnthropic({ apiKey: 'test-key', prompt: 'hello' })).rejects.toThrow( 'DNS lookup failed', From e2f58f3a7de6f1896ee9152b17970d2a4f42ae0b Mon Sep 17 00:00:00 2001 From: Nadia Ujovich <48018975+nujovich@users.noreply.github.com> Date: Mon, 11 May 2026 19:53:20 +0200 Subject: [PATCH 19/19] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- lib/net-utils.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/net-utils.mjs b/lib/net-utils.mjs index c65ac32..3fd17c7 100644 --- a/lib/net-utils.mjs +++ b/lib/net-utils.mjs @@ -1,6 +1,7 @@ // Internal network/environment utilities for lib/prompts.mjs. -// Not part of the published package surface — import directly from this -// module in tests rather than going through prompts.mjs. +// This module may be shipped as an implementation detail, but it is not a +// supported public API; import it directly in tests rather than going through +// prompts.mjs. import { readFileSync } from 'node:fs'