fix: thread injectable fetch through read tool and remote compaction

Completes the injectable-fetch transport wiring (15.10.8) that the feature
left half-done, fixing the deterministic CI test failures:

- compaction.compact() rebuilt summaryOptions field-by-field but dropped
  `fetch`, so the injected transport never reached
  requestOpenAiRemoteCompaction / generateSummary's remote path. Thread it.
- Read-tool URL pipeline had no fetch seam: renderHtmlToText gained a
  fetchOverride param but renderUrl/ToolSession never carried one, so the
  jina/parallel reader backends always used global fetch. Add
  ToolSession.fetch -> renderUrl -> renderHtmlToText (defaults to global).
- searchWithParallel mirrored extractWithParallel but missed the fetch
  option; add it.
- Repair tests whose deleted hookFetch interceptors were never replaced
  with a FetchImpl seam (fetch-kagi-toggle, web-search-parallel,
  issue-970 discovery).
- Update issue-1746 POSIX case to the #2154 preserved-scrollback contract:
  unknown-viewport streaming deferral is now platform-independent.
This commit is contained in:
can1357
2026-06-09 05:44:35 +02:00
parent 74d4f00928
commit c69ba70a4f
8 changed files with 59 additions and 24 deletions
@@ -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);
+13 -4
View File
@@ -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<FetchRenderResult> {
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;
+3 -1
View File
@@ -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) */
+3 -1
View File
@@ -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({
@@ -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");
@@ -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 = "<html><body><nav>Docs</nav><main><h1>Widget</h1></main></body></html>";
@@ -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 = "<html><body><main><h1>Parallel Page</h1></main></body></html>";
const ensureToolSpy = vi.spyOn(toolsManager, "ensureTool");
const htmlToMarkdownSpy = vi.spyOn(natives, "htmlToMarkdown");
@@ -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"],
+9 -7
View File
@@ -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();
}