From eeb098c5a423c9bb71863b33b6b87e79c3175100 Mon Sep 17 00:00:00 2001 From: Diogo Felix Date: Mon, 2 Nov 2026 13:04:17 -0300 Subject: [PATCH] Pass a version-gated isStreaming thunk to pi's markdown transform pi 1.5.0 reads the render context's isStreaming through a thunk, so the patched renderer now supplies (() => component.isStreaming) on pi >=1.5 and the raw boolean below. Passing a boolean to 1.5.0 threw "isStreaming is not a function" during render and killed the process. The gate reads the host's own version (VERSION export), treats an unparseable version as "needs thunk", and the host smoke test now renders a text block so the transform path runs against the matrix host. Adds pi 1.5.0 to the compatibility matrix. --- .github/workflows/ci.yml | 2 +- internal-patch.ts | 34 +++++++++++++++------------------- scripts/test-host.mjs | 5 +++++ test/thinking-steps.test.ts | 28 ++++++++++++++++++++++++++++ 4 files changed, 49 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d12deb..ff2c124 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: max-parallel: 1 matrix: node: ["22.19.0", "24"] - pi: ["0.99.2", "1.0.0"] + pi: ["0.99.2", "1.0.0", "1.5.0"] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 diff --git a/internal-patch.ts b/internal-patch.ts index 2000737..9167b67 100644 --- a/internal-patch.ts +++ b/internal-patch.ts @@ -1,7 +1,7 @@ import { join } from "node:path"; import { pathToFileURL } from "node:url"; import type { AssistantMessage, ThinkingContent } from "@earendil-works/pi-ai"; -import { AssistantMessageComponent as HostAssistantMessageComponent, getPackageDir, type MarkdownTransformer } from "@earendil-works/pi-coding-agent"; +import { AssistantMessageComponent as HostAssistantMessageComponent, getPackageDir, VERSION, type MarkdownTransformer } from "@earendil-works/pi-coding-agent"; import { Markdown, Spacer, Text, type Component, type MarkdownOptions, type MarkdownTheme } from "@earendil-works/pi-tui"; import { decrementPatchRefCount, getPatchCleanup, getPatchInstallPromise, incrementPatchRefCount, resolveThinkingMessageScope, setPatchCleanup, setPatchInstallPromise } from "./state.js"; import { ThinkingStepsComponent } from "./render.js"; @@ -78,18 +78,6 @@ function hasPatchableContentContainer(value: AssistantMessageComponentPrototype) ); } -function fallbackToOriginalUpdateContent( - instance: AssistantMessageComponentPrototype, - message: AssistantMessage, - originalUpdateContent: (message: AssistantMessage) => void, -): void { - try { - originalUpdateContent.call(instance, message); - } catch (error) { - throw new Error("Thinking Steps patch failed: Pi internals are incompatible and fallback rendering also failed.", { cause: error }); - } -} - export function resolvePiCodingAgentInternalModuleUrl(relativePath: string): string { try { return pathToFileURL(join(getPackageDir(), relativePath)).href; @@ -109,6 +97,17 @@ export async function importPiCodingAgentInternal(relativePath: string) } } +export function needsIsStreamingThunk(version: string): boolean { + const [major = 0, minor = 0] = version.split(".").map(Number); + return !(major < 1 || (major === 1 && minor < 5)); +} + +const PI_TAKES_IS_STREAMING_THUNK = needsIsStreamingThunk(VERSION); + +export function isStreamingArgument(component: { isStreaming: boolean }): boolean | (() => boolean) { + return PI_TAKES_IS_STREAMING_THUNK ? () => component.isStreaming : component.isStreaming; +} + function hasVisibleThinking(content: ThinkingContent): boolean { return content.redacted === true || content.thinking.trim().length > 0; } @@ -131,17 +130,13 @@ function hasVisibleTextContent(message: AssistantMessage): boolean { return message.content.some((content) => content.type === "text" && content.text.trim().length > 0); } -function hasVisibleThinkingContent(message: AssistantMessage): boolean { - return message.content.some((content) => content.type === "thinking" && hasVisibleThinking(content)); -} - async function installPatch(providedTheme?: ThinkingThemeLike): Promise<() => void> { const [{ theme: rawTheme }, { createMarkdownTransform }] = await Promise.all([ providedTheme === undefined ? importPiCodingAgentInternal<{ theme: unknown }>(PI_CODING_AGENT_INTERNAL_MODULES.theme) : Promise.resolve({ theme: providedTheme }), importPiCodingAgentInternal<{ - createMarkdownTransform(messageType: "assistant", isStreaming: boolean, transformers: MarkdownTransformer[]): NonNullable; + createMarkdownTransform(messageType: "assistant", isStreaming: boolean | (() => boolean), transformers: MarkdownTransformer[]): NonNullable; }>(PI_CODING_AGENT_INTERNAL_MODULES.markdownTransform), ]); @@ -298,10 +293,11 @@ async function installPatch(providedTheme?: ThinkingThemeLike): Promise<() => vo return message.content.slice(firstThinkingIndex + 1).some((content) => content.type === "text" && content.text.trim().length > 0); })(); + const streamingArgument = isStreamingArgument(this); for (const content of message.content) { if (content.type === "text" && content.text.trim()) { this.contentContainer.addChild(new Markdown(content.text.trim(), this.outputPad, 0, this.markdownTheme, undefined, { - transform: createMarkdownTransform("assistant", this.isStreaming, this.markdownTransformers), + transform: createMarkdownTransform("assistant", streamingArgument, this.markdownTransformers), })); continue; } diff --git a/scripts/test-host.mjs b/scripts/test-host.mjs index fe2399b..9ce57b6 100644 --- a/scripts/test-host.mjs +++ b/scripts/test-host.mjs @@ -65,6 +65,11 @@ try { assert.match(component.render(100).join("\n"), /Waiting for thinking content/); component.updateContent({ ...message, content: [] }, false); assert.match(component.render(100).join("\n"), /No thinking content supplied/); + // Text blocks go through the host's Markdown transform, which takes the streaming flag in a version-specific shape. + component.updateContent({ ...message, content: [{ type: "thinking", thinking: "Verify the host renderer." }, { type: "text", text: "HOST_TEXT_ANSWER" }] }, true); + assert.match(component.render(100).join("\n"), /HOST_TEXT_ANSWER/); + component.updateContent({ ...message, content: [{ type: "text", text: "HOST_TEXT_ANSWER" }] }, false); + assert.match(component.render(100).join("\n"), /HOST_TEXT_ANSWER/); } const shortcut = extension.shortcuts.get("alt+t"); assert.ok(shortcut); diff --git a/test/thinking-steps.test.ts b/test/thinking-steps.test.ts index c1e3f35..b642d50 100644 --- a/test/thinking-steps.test.ts +++ b/test/thinking-steps.test.ts @@ -11,6 +11,8 @@ import { assertPatchableAssistantMessageComponent, assertThinkingStepsTheme, importPiCodingAgentInternal, + isStreamingArgument, + needsIsStreamingThunk, PI_CODING_AGENT_INTERNAL_MODULES, resolvePiCodingAgentInternalModuleUrl, retainThinkingStepsPatch, @@ -388,6 +390,32 @@ describe("patch guards", () => { }); }); +describe("isStreaming argument compatibility", () => { + it("builds a transform that renders without throwing and reports a boolean isStreaming", async () => { + const host = await importPiCodingAgentInternal<{ + createMarkdownTransform(messageType: "assistant", isStreaming: boolean | (() => boolean), transformers: MarkdownTransformer[]): (markdown: string, availableWidth: number) => string; + }>(PI_CODING_AGENT_INTERNAL_MODULES.markdownTransform); + let seen: unknown; + const transform = host.createMarkdownTransform("assistant", isStreamingArgument({ isStreaming: true }), [ + (markdown, context) => { + seen = context.isStreaming; + return markdown; + }, + ]); + + assert.equal(transform("hello", 80), "hello"); + assert.equal(seen, true); + }); + + it("only asks for the thunk on pi >=1.5", () => { + assert.equal(needsIsStreamingThunk("0.99.2"), false); + assert.equal(needsIsStreamingThunk("1.0.0"), false); + assert.equal(needsIsStreamingThunk("1.4.9"), false); + assert.equal(needsIsStreamingThunk("1.5.0"), true); + assert.equal(needsIsStreamingThunk("2.0.0"), true); + assert.equal(needsIsStreamingThunk("garbage"), true); + }); +}); describe("iconForThinkingRole", () => { it("uses a restrained icon set with distinct inspect and search glyphs", () => {