From 33b60b4ddc72ec13b71b8c61bc7c2138da3cfc95 Mon Sep 17 00:00:00 2001 From: Krisjanis Berzins Date: Thu, 30 Jul 2026 22:35:21 +0100 Subject: [PATCH] fix: install undici globals so provider responses decompress on Node 26 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every reply rendered as the "✅ Done" fallback: assistant messages came back with empty content, zero usage and stopReason "stop". Importing @earendil-works/pi-coding-agent installs an npm-undici global dispatcher as a module side effect. Node 26's bundled fetch honours that dispatcher but does not decompress responses that travelled through it, so a gzipped text/event-stream reaches the provider parser as raw gzip bytes. The stream yields no events and the assistant message ends up empty. Verified against api.anthropic.com: HTTP 200, body magic 1f8b08, and the decompressed SSE contained the expected text_delta events. Pi's own CLI avoids this by calling configureHttpDispatcher() in dist/cli.js, which ends with undici.install(). That helper is not part of the package's public exports, so embedders have to install the undici globals themselves. Add configureHttpRuntime() and call it from both entry points (cli.ts for the binary, startBot() for library use). It is idempotent. --- package-lock.json | 12 +++++++++++- package.json | 3 ++- src/cli.ts | 4 ++++ src/http-runtime.ts | 31 +++++++++++++++++++++++++++++++ src/index.ts | 5 +++++ test/http-runtime.test.ts | 39 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 src/http-runtime.ts create mode 100644 test/http-runtime.test.ts diff --git a/package-lock.json b/package-lock.json index c9f6e89..e1f0967 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,8 @@ "@earendil-works/pi-coding-agent": "^0.82.1", "@grammyjs/auto-retry": "^2.0.2", "grammy": "^1.35.0", - "minimatch": "^10.2.4" + "minimatch": "^10.2.4", + "undici": "^8.9.0" }, "bin": { "telepi": "dist/cli.js" @@ -5523,6 +5524,15 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", + "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/package.json b/package.json index 855a421..5cba105 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,8 @@ "@earendil-works/pi-coding-agent": "^0.82.1", "@grammyjs/auto-retry": "^2.0.2", "grammy": "^1.35.0", - "minimatch": "^10.2.4" + "minimatch": "^10.2.4", + "undici": "^8.9.0" }, "optionalDependencies": { "parakeet-coreml": "^2.2.0", diff --git a/src/cli.ts b/src/cli.ts index 39cdeb2..6ed0616 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,8 +1,12 @@ #!/usr/bin/env node import { isEntrypoint } from "./entrypoint.js"; +import { configureHttpRuntime } from "./http-runtime.js"; import { getTelePiStatus, resolveTelePiInstallContext, setupTelePi } from "./install.js"; import { startBot } from "./index.js"; +// Must run before any provider request is issued. See http-runtime.ts. +configureHttpRuntime(); + const HELP_TEXT = `TelePi CLI Usage: diff --git a/src/http-runtime.ts b/src/http-runtime.ts new file mode 100644 index 0000000..329b1d9 --- /dev/null +++ b/src/http-runtime.ts @@ -0,0 +1,31 @@ +import { install } from "undici"; + +/** + * Align `globalThis.fetch` with undici's global dispatcher. + * + * Importing `@earendil-works/pi-coding-agent` installs an npm-undici global + * dispatcher as a module side effect. Node's *bundled* fetch honours that + * dispatcher but, on Node 26, does not decompress responses that came through + * it — so a gzipped `text/event-stream` reaches the provider parser as raw + * gzip bytes. The stream yields no events, every reply becomes an empty + * assistant message, and TelePi renders its "✅ Done" fallback. + * + * Pi's own CLI avoids this by calling `configureHttpDispatcher()` in its entry + * point, which ends with `undici.install()`. That helper is not part of the + * package's public exports, so embedders have to install the undici globals + * themselves. + * + * Call this once, as early as possible, before any provider request is issued. + */ +export function configureHttpRuntime(): void { + if (installed) { + return; + } + installed = true; + + // `install` was added in undici v7.11; older copies simply keep the + // bundled fetch, which is the pre-existing behaviour. + install?.(); +} + +let installed = false; diff --git a/src/index.ts b/src/index.ts index dd9272c..8e79be3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import { createBot, registerCommands } from "./bot.js"; import { loadConfig } from "./config.js"; import { isEntrypoint } from "./entrypoint.js"; +import { configureHttpRuntime } from "./http-runtime.js"; import { PiSessionRegistry } from "./pi-session.js"; const MAX_RESTART_ATTEMPTS = 5; @@ -12,6 +13,10 @@ export async function startBot(): Promise { let shuttingDown = false; let restartAttempts = 0; + // Idempotent: `telepi` sets this up in cli.ts, but startBot() is also the + // documented library entry point. See http-runtime.ts. + configureHttpRuntime(); + try { const config = loadConfig(); sessionRegistry = await PiSessionRegistry.create(config); diff --git a/test/http-runtime.test.ts b/test/http-runtime.test.ts new file mode 100644 index 0000000..e62d8bc --- /dev/null +++ b/test/http-runtime.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; + +import { configureHttpRuntime } from "../src/http-runtime.js"; + +const UNDICI_GLOBAL_DISPATCHER = Symbol.for("undici.globalDispatcher.1"); + +describe("configureHttpRuntime", () => { + it("replaces the bundled fetch so it shares undici's global dispatcher", async () => { + const bundledFetch = globalThis.fetch; + + configureHttpRuntime(); + + const { install } = await import("undici"); + if (typeof install !== "function") { + // Older undici copies have no install(); nothing to assert. + expect(globalThis.fetch).toBe(bundledFetch); + return; + } + + expect(globalThis.fetch).not.toBe(bundledFetch); + }); + + it("is idempotent", () => { + configureHttpRuntime(); + const installedFetch = globalThis.fetch; + + configureHttpRuntime(); + + expect(globalThis.fetch).toBe(installedFetch); + }); + + it("leaves undici's global dispatcher in place", () => { + configureHttpRuntime(); + + expect( + (globalThis as Record)[UNDICI_GLOBAL_DISPATCHER], + ).toBeDefined(); + }); +});