diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d28d8eed..4c0f9a33 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed extension input events to report `streamingBehavior` only for prompts actually queued during streaming ([#5107](https://github.com/earendil-works/pi-mono/pull/5107)). - Fixed fenced `diff` code blocks and other highlight.js scopes to keep theme-aware syntax colors after the `cli-highlight` replacement ([#5092](https://github.com/earendil-works/pi/issues/5092)). ## [0.76.0] - 2026-05-27 diff --git a/packages/coding-agent/examples/extensions/README.md b/packages/coding-agent/examples/extensions/README.md index b7521df2..fc2049e6 100644 --- a/packages/coding-agent/examples/extensions/README.md +++ b/packages/coding-agent/examples/extensions/README.md @@ -75,6 +75,7 @@ cp permission-gate.ts ~/.pi/agent/extensions/ | `reload-runtime.ts` | Adds `/reload-runtime` and `reload_runtime` tool showing safe reload flow | | `interactive-shell.ts` | Run interactive commands (vim, htop) with full terminal via `user_bash` hook | | `inline-bash.ts` | Expands `!{command}` patterns in prompts via `input` event transformation | +| `input-transform-streaming.ts` | Skips expensive input preprocessing for mid-stream steering via `streamingBehavior` | ### Git Integration diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 457d77f5..3df2a85a 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -984,7 +984,7 @@ export class AgentSession { currentText, currentImages, options?.source ?? "interactive", - options?.streamingBehavior, + this.isStreaming ? options?.streamingBehavior : undefined, ); if (inputResult.action === "handled") { preflightResult?.(true); diff --git a/packages/coding-agent/test/suite/agent-session-prompt.test.ts b/packages/coding-agent/test/suite/agent-session-prompt.test.ts index dde25f61..94e10a3e 100644 --- a/packages/coding-agent/test/suite/agent-session-prompt.test.ts +++ b/packages/coding-agent/test/suite/agent-session-prompt.test.ts @@ -5,6 +5,7 @@ import type { AgentTool } from "@earendil-works/pi-agent-core"; import { fauxAssistantMessage, fauxToolCall, type Model } from "@earendil-works/pi-ai"; import { Type } from "typebox"; import { afterEach, describe, expect, it } from "vitest"; +import type { InputEvent } from "../../src/core/extensions/index.ts"; import type { PromptTemplate } from "../../src/core/prompt-templates.ts"; import { createSyntheticSourceInfo } from "../../src/core/source-info.ts"; import { createTestResourceLoader } from "../utilities.ts"; @@ -259,6 +260,80 @@ describe("AgentSession prompt characterization", () => { expect(getMessageText(harness.session.messages[0]!)).toBe("from extension"); }); + it("does not report streamingBehavior to input handlers while idle", async () => { + const inputEvents: InputEvent[] = []; + const harness = await createHarness({ + extensionFactories: [ + (pi) => { + pi.on("input", (event) => { + inputEvents.push(event); + }); + }, + ], + }); + harnesses.push(harness); + harness.setResponses([fauxAssistantMessage("ok")]); + + await harness.session.prompt("idle", { streamingBehavior: "followUp" }); + + expect(inputEvents).toHaveLength(1); + expect(inputEvents[0]?.streamingBehavior).toBeUndefined(); + }); + + it("reports streamingBehavior to input handlers while streaming", async () => { + let releaseToolExecution: (() => void) | undefined; + const toolRelease = new Promise((resolve) => { + releaseToolExecution = resolve; + }); + const inputEvents: InputEvent[] = []; + const waitTool: AgentTool = { + name: "wait", + label: "Wait", + description: "Wait for release", + parameters: Type.Object({}), + execute: async () => { + await toolRelease; + return { + content: [{ type: "text", text: "released" }], + details: {}, + }; + }, + }; + const harness = await createHarness({ + tools: [waitTool], + extensionFactories: [ + (pi) => { + pi.on("input", (event) => { + inputEvents.push(event); + }); + }, + ], + }); + harnesses.push(harness); + harness.setResponses([ + fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }), + fauxAssistantMessage("done"), + ]); + + const sawToolStart = new Promise((resolve) => { + const unsubscribe = harness.session.subscribe((event) => { + if (event.type === "tool_execution_start") { + unsubscribe(); + resolve(); + } + }); + }); + + const promptPromise = harness.session.prompt("start"); + await sawToolStart; + await harness.session.prompt("queued", { streamingBehavior: "followUp" }); + + expect(inputEvents.map((event) => event.streamingBehavior)).toEqual([undefined, "followUp"]); + + releaseToolExecution?.(); + await promptPromise; + }); + it("throws when prompted during streaming without a streamingBehavior", async () => { let releaseToolExecution: (() => void) | undefined; const toolRelease = new Promise((resolve) => {