Skip to content

fix: install undici globals so provider responses decompress on Node 26 - #23

Closed
krisj wants to merge 1 commit into
benedict2310:mainfrom
krisj:fix/node26-undici-fetch-decompression
Closed

krisj wants to merge 1 commit into
benedict2310:mainfrom
krisj:fix/node26-undici-fetch-decompression

Conversation

@krisj

@krisj krisj commented Jul 30, 2026

Copy link
Copy Markdown

Problem

On Node 26, every TelePi reply renders as the ✅ Done fallback.

The assistant messages are genuinely empty — session JSONL shows:

{"role":"assistant","content":[],"usage":{"input":0,"output":0,"totalTokens":0},"stopReason":"stop"}

prompt-handler.ts is behaving correctly; buildFinalResponseText() gets an
empty string and falls back to ✅ Done.

Root cause

Importing @earendil-works/pi-coding-agent installs an npm-undici global
dispatcher as a module side effect (verified: Symbol.for("undici.globalDispatcher.1")
goes from undefined to a dispatcher purely on import).

Node 26's bundled fetch honours that dispatcher but does not decompress
responses that travelled through it. A gzipped text/event-stream therefore
reaches the provider parser as raw gzip bytes, the stream yields no events, and
the assistant message ends up empty with stopReason: "stop".

Instrumenting globalThis.fetch in an embedder process confirmed it end to end:

[FETCH] https://api.anthropic.com/v1/messages -> 200, bodyLen=620
magic: 1f8b08          # gzip, still compressed

Decompressing that body by hand shows the response was fine all along:

event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"P"}}
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ONG-LIB"}}

Pi's own CLI never hits this because dist/cli.js calls
configureHttpDispatcher() before anything issues a request, and that helper
ends with undici.install(). Its source comment names the exact failure mode:

Keep fetch and the dispatcher on the same undici implementation. Node 26.0's
bundled fetch can otherwise consume compressed responses through npm undici's
dispatcher without decompressing them, causing response.json() failures.

configureHttpDispatcher is not part of the package's public exports (only .
and ./rpc-entry), so embedders like TelePi have to install the undici globals
themselves.

Fix

Add configureHttpRuntime() (src/http-runtime.ts), which calls
undici.install() once, and invoke it from both entry points:

  • src/cli.ts — for the telepi binary
  • startBot() in src/index.ts — for library use

It is idempotent, and install?.() is optional-called so older undici copies
without install keep today's behaviour.

Verification

  • Reproduced against the pi SDK outside the bot, then confirmed
    undici.install() fixes it: empty string → the model's actual reply.
  • Re-verified through the built dist/http-runtime.js before restarting the
    real service.
  • Ran the live bot after the fix.
  • npm test: 426 tests / 32 files pass, including 3 new tests for
    configureHttpRuntime.

Notes

Arguably pi should also export configureHttpDispatcher (or call
undici.install() wherever it sets the global dispatcher) so embedders can't
miss it. Worth raising upstream — but TelePi shouldn't have to wait for that.

Repro environment: Node 26.0.0, macOS 26.3.1 arm64, TelePi 0.4.2,
@earendil-works/pi-coding-agent 0.82.1, provider anthropic.

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.

@benedict2310 benedict2310 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the excellent root-cause analysis and focused fix. I reproduced the branch test/build health on both Node 23.11 and Node 26.7 (426 tests pass on each), but found two blockers before merge:

  1. configureHttpRuntime() unconditionally calls undici.install(). For library consumers, this overwrites a deliberate globalThis.fetch override. Pi's own configureHttpDispatcher() guards this by recording the original/installed fetch and only reinstalling when fetch has not been replaced by the caller. TelePi should preserve the same behavior, with a regression test for a custom fetch override.

  2. The new tests verify global identity/idempotence but not the reported gzip failure. Please add an isolated Node 26 regression test (child process + local gzip/SSE server is sufficient) that demonstrates the response is decompressed with the Pi/npm-undici dispatcher interaction. That will also verify the root Undici 8.9 + Pi's nested Undici integration.

Minor cleanup: the static named install import means install?.() cannot actually support an older Undici package lacking that export, and CLI setup/status/help do not need the global mutation because startBot() is the runtime entry point.

@benedict2310

Copy link
Copy Markdown
Owner

Thanks again for identifying this Node 26 compatibility issue. I rebuilt the fix on current main as #25 rather than merging this stale branch: it preserves intentional fetch overrides, applies the runtime setup only when the bot starts, and adds a real isolated gzip/SSE regression exercised on Node 26 CI. #25 is now merged, so I’m closing this PR as superseded.

@benedict2310

Copy link
Copy Markdown
Owner

Superseded by merged #25.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants