diff --git a/packages/agent/src/compaction/compaction.ts b/packages/agent/src/compaction/compaction.ts index 9bb2f0106..e06aa9fd5 100644 --- a/packages/agent/src/compaction/compaction.ts +++ b/packages/agent/src/compaction/compaction.ts @@ -996,6 +996,7 @@ export async function compact( // silently falls back to Effort.High — the same defect e07b47ee4 fixed // at the call sites, leaked back in here. See resolveCompactionEffort. thinkingLevel: options?.thinkingLevel, + fetch: options?.fetch, }; let preserveData = withOpenAiRemoteCompactionPreserveData(previousPreserveData, undefined); diff --git a/packages/coding-agent/src/tools/fetch.ts b/packages/coding-agent/src/tools/fetch.ts index 3bb2284ff..4a97b4ef0 100644 --- a/packages/coding-agent/src/tools/fetch.ts +++ b/packages/coding-agent/src/tools/fetch.ts @@ -3,7 +3,7 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import type { AgentToolResult } from "@oh-my-pi/pi-agent-core"; -import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai"; +import type { FetchImpl, ImageContent, TextContent } from "@oh-my-pi/pi-ai"; import { htmlToMarkdown } from "@oh-my-pi/pi-natives"; import { type Component, Text } from "@oh-my-pi/pi-tui"; import { $which, ptree, truncate } from "@oh-my-pi/pi-utils"; @@ -637,7 +637,7 @@ export async function renderHtmlToText( settings: Settings, userSignal: AbortSignal | undefined, storage: AgentStorage | null, - fetchOverride?: typeof fetch, + fetchOverride?: FetchImpl, ): Promise<{ content: string; ok: boolean; method: string }> { const overallSignal = ptree.combineSignals(userSignal, timeout * 1000); const execOptions = { @@ -1060,6 +1060,7 @@ async function renderUrl( settings: Settings, signal: AbortSignal | undefined, storage: AgentStorage | null, + fetchOverride?: FetchImpl, ): Promise { const notes: string[] = []; const fetchedAt = new Date().toISOString(); @@ -1433,7 +1434,15 @@ async function renderUrl( } // 5E: Render HTML via the reader-backend chain (native/trafilatura/lynx/parallel/jina) - const htmlResult = await renderHtmlToText(finalUrl, rawContent, timeout, settings, signal, storage); + const htmlResult = await renderHtmlToText( + finalUrl, + rawContent, + timeout, + settings, + signal, + storage, + fetchOverride, + ); if (!htmlResult.ok) { notes.push("html rendering failed (no reader backend produced usable output)"); @@ -1634,7 +1643,7 @@ async function buildReadUrlCacheEntry( } const storage = session.settings.getStorage(); - const result = await renderUrl(url, effectiveTimeout, raw, session.settings, signal, storage); + const result = await renderUrl(url, effectiveTimeout, raw, session.settings, signal, storage, session.fetch); const output = buildUrlReadOutput(result, result.content); const artifactId = options?.ensureArtifact ? await persistReadUrlArtifact(session, output) : undefined; diff --git a/packages/coding-agent/src/tools/index.ts b/packages/coding-agent/src/tools/index.ts index 944c764fe..4057ab678 100644 --- a/packages/coding-agent/src/tools/index.ts +++ b/packages/coding-agent/src/tools/index.ts @@ -1,6 +1,6 @@ import type { InMemorySnapshotStore } from "@oh-my-pi/hashline"; import type { AgentTelemetryConfig, AgentTool } from "@oh-my-pi/pi-agent-core"; -import type { ToolChoice } from "@oh-my-pi/pi-ai"; +import type { FetchImpl, ToolChoice } from "@oh-my-pi/pi-ai"; import { logger } from "@oh-my-pi/pi-utils"; import type { AsyncJobManager } from "../async/job-manager"; import type { PromptTemplate } from "../config/prompt-templates"; @@ -142,6 +142,8 @@ export interface ToolSession { cwd: string; /** Whether UI is available */ hasUI: boolean; + /** Optional fetch implementation injected into the URL read pipeline (tests, proxies). Defaults to global fetch. */ + fetch?: FetchImpl; /** Skip Python kernel availability check and warmup */ skipPythonPreflight?: boolean; /** Pre-loaded context files (AGENTS.md, etc) */ diff --git a/packages/coding-agent/src/web/parallel.ts b/packages/coding-agent/src/web/parallel.ts index d7be59693..487f28225 100644 --- a/packages/coding-agent/src/web/parallel.ts +++ b/packages/coding-agent/src/web/parallel.ts @@ -54,6 +54,7 @@ export interface ParallelSearchOptions { mode?: "fast" | "research"; maxCharsPerResult?: number; signal?: AbortSignal; + fetch?: FetchImpl; } export interface ParallelExtractOptions { @@ -296,7 +297,8 @@ export async function searchWithParallel( ); } - const response = await fetch(PARALLEL_SEARCH_URL, { + const fetchImpl = options.fetch ?? fetch; + const response = await fetchImpl(PARALLEL_SEARCH_URL, { method: "POST", headers: getAuthHeaders(apiKey), body: JSON.stringify({ diff --git a/packages/coding-agent/test/issue-970-custom-provider-discovery.test.ts b/packages/coding-agent/test/issue-970-custom-provider-discovery.test.ts index 1a647de79..4569869ae 100644 --- a/packages/coding-agent/test/issue-970-custom-provider-discovery.test.ts +++ b/packages/coding-agent/test/issue-970-custom-provider-discovery.test.ts @@ -118,13 +118,7 @@ describe("issue #970 custom provider discovery", () => { }); }; - // NOTE: ModelRegistryImpl has no fetch injection seam; fetchMock cannot be - // passed to the constructor. Missing API: ModelRegistry constructor (or - // refreshProvider) must accept a `fetch` option to avoid global override. - // Tracked: packages/coding-agent/src/config/model-registry.ts #discoverOpenAIModelsList - void fetchMock; - - const registry = new ModelRegistryImpl(authStorage, modelsPath); + const registry = new ModelRegistryImpl(authStorage, modelsPath, { fetch: fetchMock }); await registry.refreshProvider("vllm"); const providerModels = registry.getAll().filter(model => model.provider === "vllm"); diff --git a/packages/coding-agent/test/tools/fetch-kagi-toggle.test.ts b/packages/coding-agent/test/tools/fetch-kagi-toggle.test.ts index 4c5c8930a..56c13c2b5 100644 --- a/packages/coding-agent/test/tools/fetch-kagi-toggle.test.ts +++ b/packages/coding-agent/test/tools/fetch-kagi-toggle.test.ts @@ -11,6 +11,7 @@ import * as scrapers from "@oh-my-pi/pi-coding-agent/web/scrapers/types"; import * as scraperUtils from "@oh-my-pi/pi-coding-agent/web/scrapers/utils"; import * as natives from "@oh-my-pi/pi-natives"; import { ptree, Snowflake } from "@oh-my-pi/pi-utils"; +import { asGlobalFetch } from "../helpers/fetch-mock"; const withMissingSystemPython = () => { const whichSpy = vi.spyOn(Bun, "which").mockImplementation(() => null); @@ -494,6 +495,7 @@ describe("read tool URL handling", () => { it("uses section-scoped llms.txt fallback without requesting the site-wide file", async () => { const session = createSession(); + session.fetch = asGlobalFetch(() => new Response("blocked", { status: 500, statusText: "Blocked" })); const tool = new ReadTool(session); const pageUrl = "https://example.com/docs/reference/widget"; const pageHtml = "

Widget

"; @@ -577,6 +579,32 @@ describe("read tool URL handling", () => { const session = createSession({ "providers.fetch": "parallel" }); const tool = new ReadTool(session); const pageUrl = "https://example.com/parallel-page"; + session.fetch = asGlobalFetch(input => { + if (String(input) === "https://api.parallel.ai/v1beta/extract") { + return new Response( + JSON.stringify({ + extract_id: "extract-fetch-1", + results: [ + { + url: pageUrl, + title: "Parallel Page", + excerpts: [ + "Parallel-rendered content that is comfortably longer than one hundred characters. ".repeat( + 2, + ), + ], + full_content: null, + }, + ], + errors: [], + warnings: null, + usage: null, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + return new Response("blocked", { status: 500, statusText: "Blocked" }); + }); const pageHtml = "

Parallel Page

"; const ensureToolSpy = vi.spyOn(toolsManager, "ensureTool"); const htmlToMarkdownSpy = vi.spyOn(natives, "htmlToMarkdown"); diff --git a/packages/coding-agent/test/tools/web-search-parallel.test.ts b/packages/coding-agent/test/tools/web-search-parallel.test.ts index 5afcf7a2f..92369cfa1 100644 --- a/packages/coding-agent/test/tools/web-search-parallel.test.ts +++ b/packages/coding-agent/test/tools/web-search-parallel.test.ts @@ -78,10 +78,7 @@ describe("Parallel web search", () => { usage: [{ name: "sku_search", count: 1 }], }); - // NOTE: searchWithParallel (web/parallel.ts) has no fetch seam; global fetch still used here. - void fetchMock; - - const result = await searchWithParallel("parallel query", ["parallel query"], {}, fakeStorage); + const result = await searchWithParallel("parallel query", ["parallel query"], { fetch: fetchMock }, fakeStorage); expect(capturedRequestBody).toEqual({ objective: "parallel query", search_queries: ["parallel query"], diff --git a/packages/tui/test/issue-1746-repro.test.ts b/packages/tui/test/issue-1746-repro.test.ts index 8616f172f..c6d081034 100644 --- a/packages/tui/test/issue-1746-repro.test.ts +++ b/packages/tui/test/issue-1746-repro.test.ts @@ -211,12 +211,14 @@ describe("issue #1746: scrolled reader in a non-WT ConPTY host (Tabby)", () => { }); }); - it("keeps POSIX eager streaming rebuilds destructive (win32 guard must not leak)", async () => { - // Control case: on POSIX (non-ED3-risk terminal), the same eager - // streaming mutation IS allowed to rebuild history live — that is the - // documented purpose of setEagerNativeScrollbackRebuild. The win32 - // deferral must stay scoped to Windows, or streaming tool output would - // stop reaching native scrollback on POSIX until the next checkpoint. + it("defers unknown-viewport streaming rebuilds on POSIX too (deferral is platform-independent)", async () => { + // Companion to the win32 case: an unknown viewport probe is not proof the + // reader is at the tail on ANY platform, so #2154 routes every eager + // streaming mutation over an unknown viewport through incremental + // append/repaint primitives instead of the destructive historyRebuild + // (ED3). The deferral is not a win32-only guard — POSIX with an unknown + // viewport defers identically, keeping offscreen growth in scrollback + // instead of clearing it once per streaming tick. See #2154. await withEnvPatch(CONPTY_HOST_ENV, async () => { await withPlatform("linux", async () => { await withTerminalRisk(false, async () => { @@ -240,7 +242,7 @@ describe("issue #1746: scrolled reader in a non-WT ConPTY host (Tabby)", () => { tui.requestRender(); await settle(term); - expect(eraseScrollbackCount(writes)).toBe(1); + expect(eraseScrollbackCount(writes)).toBe(0); } finally { tui.stop(); }