feat(coding-agent): expose streamingBehavior on InputEvent
Add streamingBehavior to InputEvent so extensions can distinguish idle prompts from mid-stream steers and queued follow-ups. - Add streamingBehavior field to InputEvent type - Thread it through ExtensionRunner.emitInput() and AgentSession.prompt() - Add streaming-aware input gate example with tests - Document in extensions.md
This commit is contained in:
@@ -819,6 +819,9 @@ pi.on("input", async (event, ctx) => {
|
|||||||
// event.text - raw input (before skill/template expansion)
|
// event.text - raw input (before skill/template expansion)
|
||||||
// event.images - attached images, if any
|
// event.images - attached images, if any
|
||||||
// event.source - "interactive" (typed), "rpc" (API), or "extension" (via sendUserMessage)
|
// event.source - "interactive" (typed), "rpc" (API), or "extension" (via sendUserMessage)
|
||||||
|
// event.streamingBehavior - "steer" | "followUp" | undefined
|
||||||
|
// undefined when idle, "steer" for mid-stream interrupts,
|
||||||
|
// "followUp" for messages queued until the agent finishes
|
||||||
|
|
||||||
// Transform: rewrite input before expansion
|
// Transform: rewrite input before expansion
|
||||||
if (event.text.startsWith("?quick "))
|
if (event.text.startsWith("?quick "))
|
||||||
@@ -847,7 +850,7 @@ pi.on("input", async (event, ctx) => {
|
|||||||
- `transform` - modify text/images, then continue to expansion
|
- `transform` - modify text/images, then continue to expansion
|
||||||
- `handled` - skip agent entirely (first handler to return this wins)
|
- `handled` - skip agent entirely (first handler to return this wins)
|
||||||
|
|
||||||
Transforms chain across handlers. See [input-transform.ts](../examples/extensions/input-transform.ts).
|
Transforms chain across handlers. See [input-transform.ts](../examples/extensions/input-transform.ts) and [input-transform-streaming.ts](../examples/extensions/input-transform-streaming.ts) for `streamingBehavior`-aware routing.
|
||||||
|
|
||||||
## ExtensionContext
|
## ExtensionContext
|
||||||
|
|
||||||
@@ -2543,6 +2546,7 @@ All examples in [examples/extensions/](../examples/extensions/).
|
|||||||
| `confirm-destructive.ts` | Confirm session changes | `on("session_before_switch")`, `on("session_before_fork")` |
|
| `confirm-destructive.ts` | Confirm session changes | `on("session_before_switch")`, `on("session_before_fork")` |
|
||||||
| `dirty-repo-guard.ts` | Warn on dirty git repo | `on("session_before_*")`, `exec` |
|
| `dirty-repo-guard.ts` | Warn on dirty git repo | `on("session_before_*")`, `exec` |
|
||||||
| `input-transform.ts` | Transform user input | `on("input")` |
|
| `input-transform.ts` | Transform user input | `on("input")` |
|
||||||
|
| `input-transform-streaming.ts` | Streaming-aware input transform | `on("input")`, `streamingBehavior` |
|
||||||
| `model-status.ts` | React to model changes | `on("model_select")`, `setStatus` |
|
| `model-status.ts` | React to model changes | `on("model_select")`, `setStatus` |
|
||||||
| `provider-payload.ts` | Inspect payloads and provider response headers | `on("before_provider_request")`, `on("after_provider_response")` |
|
| `provider-payload.ts` | Inspect payloads and provider response headers | `on("before_provider_request")`, `on("after_provider_response")` |
|
||||||
| `system-prompt-header.ts` | Display system prompt info | `on("agent_start")`, `getSystemPrompt` |
|
| `system-prompt-header.ts` | Display system prompt info | `on("agent_start")`, `getSystemPrompt` |
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Streaming-Aware Input Gate
|
||||||
|
*
|
||||||
|
* Demonstrates `event.streamingBehavior` to skip expensive pre-processing
|
||||||
|
* during mid-stream steering, where low latency matters.
|
||||||
|
*
|
||||||
|
* This extension prepends `git diff --stat` output when the user mentions
|
||||||
|
* file changes, giving the model immediate context. During steering the
|
||||||
|
* exec call is skipped so the correction reaches the model without delay.
|
||||||
|
*
|
||||||
|
* Start pi with this extension:
|
||||||
|
* pi -e ./examples/extensions/input-transform-streaming.ts
|
||||||
|
*/
|
||||||
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
|
|
||||||
|
const TRIGGER = /\b(changes?|diff|modified)\b/i;
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
pi.on("input", async (event) => {
|
||||||
|
// During steering, skip the exec call — corrections should be fast
|
||||||
|
if (event.streamingBehavior === "steer") {
|
||||||
|
return { action: "continue" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TRIGGER.test(event.text)) {
|
||||||
|
return { action: "continue" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { stdout, code } = await pi.exec("git", ["diff", "--stat"]);
|
||||||
|
if (code !== 0 || !stdout.trim()) {
|
||||||
|
return { action: "continue" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
action: "transform",
|
||||||
|
text: `${event.text}\n\nCurrent uncommitted changes:\n\`\`\`\n${stdout.trim()}\n\`\`\``,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -984,6 +984,7 @@ export class AgentSession {
|
|||||||
currentText,
|
currentText,
|
||||||
currentImages,
|
currentImages,
|
||||||
options?.source ?? "interactive",
|
options?.source ?? "interactive",
|
||||||
|
options?.streamingBehavior,
|
||||||
);
|
);
|
||||||
if (inputResult.action === "handled") {
|
if (inputResult.action === "handled") {
|
||||||
preflightResult?.(true);
|
preflightResult?.(true);
|
||||||
|
|||||||
@@ -1036,7 +1036,12 @@ export class ExtensionRunner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Emit input event. Transforms chain, "handled" short-circuits. */
|
/** Emit input event. Transforms chain, "handled" short-circuits. */
|
||||||
async emitInput(text: string, images: ImageContent[] | undefined, source: InputSource): Promise<InputEventResult> {
|
async emitInput(
|
||||||
|
text: string,
|
||||||
|
images: ImageContent[] | undefined,
|
||||||
|
source: InputSource,
|
||||||
|
streamingBehavior?: "steer" | "followUp",
|
||||||
|
): Promise<InputEventResult> {
|
||||||
const ctx = this.createContext();
|
const ctx = this.createContext();
|
||||||
let currentText = text;
|
let currentText = text;
|
||||||
let currentImages = images;
|
let currentImages = images;
|
||||||
@@ -1044,7 +1049,13 @@ export class ExtensionRunner {
|
|||||||
for (const ext of this.extensions) {
|
for (const ext of this.extensions) {
|
||||||
for (const handler of ext.handlers.get("input") ?? []) {
|
for (const handler of ext.handlers.get("input") ?? []) {
|
||||||
try {
|
try {
|
||||||
const event: InputEvent = { type: "input", text: currentText, images: currentImages, source };
|
const event: InputEvent = {
|
||||||
|
type: "input",
|
||||||
|
text: currentText,
|
||||||
|
images: currentImages,
|
||||||
|
source,
|
||||||
|
streamingBehavior,
|
||||||
|
};
|
||||||
const result = (await handler(event, ctx)) as InputEventResult | undefined;
|
const result = (await handler(event, ctx)) as InputEventResult | undefined;
|
||||||
if (result?.action === "handled") return result;
|
if (result?.action === "handled") return result;
|
||||||
if (result?.action === "transform") {
|
if (result?.action === "transform") {
|
||||||
|
|||||||
@@ -756,6 +756,8 @@ export interface InputEvent {
|
|||||||
images?: ImageContent[];
|
images?: ImageContent[];
|
||||||
/** Where the input came from */
|
/** Where the input came from */
|
||||||
source: InputSource;
|
source: InputSource;
|
||||||
|
/** How the input will be delivered during streaming, or undefined when idle */
|
||||||
|
streamingBehavior?: "steer" | "followUp";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Result from input event handler */
|
/** Result from input event handler */
|
||||||
|
|||||||
@@ -444,6 +444,7 @@ describe("AgentSession concurrent prompt guard", () => {
|
|||||||
text: string,
|
text: string,
|
||||||
images: unknown,
|
images: unknown,
|
||||||
source: "interactive" | "rpc" | "extension",
|
source: "interactive" | "rpc" | "extension",
|
||||||
|
streamingBehavior?: "steer" | "followUp",
|
||||||
) => Promise<{ action: "continue" }>;
|
) => Promise<{ action: "continue" }>;
|
||||||
emitBeforeAgentStart: (
|
emitBeforeAgentStart: (
|
||||||
prompt: string,
|
prompt: string,
|
||||||
@@ -588,6 +589,7 @@ describe("AgentSession concurrent prompt guard", () => {
|
|||||||
text: string,
|
text: string,
|
||||||
images: unknown,
|
images: unknown,
|
||||||
source: "interactive" | "rpc" | "extension",
|
source: "interactive" | "rpc" | "extension",
|
||||||
|
streamingBehavior?: "steer" | "followUp",
|
||||||
) => Promise<{ action: "continue" }>;
|
) => Promise<{ action: "continue" }>;
|
||||||
emitBeforeAgentStart: (
|
emitBeforeAgentStart: (
|
||||||
prompt: string,
|
prompt: string,
|
||||||
|
|||||||
@@ -94,6 +94,18 @@ describe("Input Event", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes streamingBehavior correctly", async () => {
|
||||||
|
const r = await createRunner(
|
||||||
|
`export default p => p.on("input", async e => { globalThis.testVar = e.streamingBehavior; return { action: "continue" }; });`,
|
||||||
|
);
|
||||||
|
await r.emitInput("x", undefined, "interactive", "steer");
|
||||||
|
expect((globalThis as any).testVar).toBe("steer");
|
||||||
|
await r.emitInput("x", undefined, "interactive", "followUp");
|
||||||
|
expect((globalThis as any).testVar).toBe("followUp");
|
||||||
|
await r.emitInput("x", undefined, "interactive");
|
||||||
|
expect((globalThis as any).testVar).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("catches handler errors and continues", async () => {
|
it("catches handler errors and continues", async () => {
|
||||||
const r = await createRunner(`export default p => p.on("input", async () => { throw new Error("boom"); });`);
|
const r = await createRunner(`export default p => p.on("input", async () => { throw new Error("boom"); });`);
|
||||||
const errs: string[] = [];
|
const errs: string[] = [];
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import inputTransformStreaming from "../examples/extensions/input-transform-streaming.ts";
|
||||||
|
import type {
|
||||||
|
ExecResult,
|
||||||
|
ExtensionAPI,
|
||||||
|
ExtensionContext,
|
||||||
|
InputEvent,
|
||||||
|
InputEventResult,
|
||||||
|
} from "../src/core/extensions/index.ts";
|
||||||
|
|
||||||
|
type InputHandler = (event: InputEvent, ctx: ExtensionContext) => Promise<InputEventResult | undefined>;
|
||||||
|
|
||||||
|
function setup(execResult: ExecResult) {
|
||||||
|
let handler: InputHandler | undefined;
|
||||||
|
|
||||||
|
const exec = vi.fn<ExtensionAPI["exec"]>().mockResolvedValue(execResult);
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
on: (event: string, h: InputHandler) => {
|
||||||
|
if (event === "input") handler = h;
|
||||||
|
},
|
||||||
|
exec,
|
||||||
|
} as unknown as ExtensionAPI;
|
||||||
|
|
||||||
|
inputTransformStreaming(api);
|
||||||
|
|
||||||
|
const ctx = {} as ExtensionContext;
|
||||||
|
|
||||||
|
function emit(text: string, streamingBehavior?: "steer" | "followUp") {
|
||||||
|
return handler!({ type: "input", text, source: "interactive", streamingBehavior }, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { emit, exec };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("input-transform-streaming example", () => {
|
||||||
|
const diffOutput = " src/index.ts | 5 ++---\n 1 file changed, 2 insertions(+), 3 deletions(-)";
|
||||||
|
const gitSuccess: ExecResult = { stdout: diffOutput, stderr: "", code: 0, killed: false };
|
||||||
|
const gitEmpty: ExecResult = { stdout: "", stderr: "", code: 0, killed: false };
|
||||||
|
const gitFail: ExecResult = { stdout: "", stderr: "not a git repo", code: 128, killed: false };
|
||||||
|
|
||||||
|
it("skips exec during steering", async () => {
|
||||||
|
const { emit, exec } = setup(gitSuccess);
|
||||||
|
const result = await emit("what changes did I make?", "steer");
|
||||||
|
expect(result).toEqual({ action: "continue" });
|
||||||
|
expect(exec).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("transforms when idle and text matches trigger", async () => {
|
||||||
|
const { emit, exec } = setup(gitSuccess);
|
||||||
|
const result = await emit("review my changes");
|
||||||
|
expect(exec).toHaveBeenCalledWith("git", ["diff", "--stat"]);
|
||||||
|
expect(result).toMatchObject({ action: "transform" });
|
||||||
|
const text = (result as { text: string }).text;
|
||||||
|
expect(text).toContain("review my changes");
|
||||||
|
expect(text).toContain("src/index.ts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("transforms when queued as follow-up", async () => {
|
||||||
|
const { emit, exec } = setup(gitSuccess);
|
||||||
|
const result = await emit("show me the diff", "followUp");
|
||||||
|
expect(exec).toHaveBeenCalled();
|
||||||
|
expect(result).toMatchObject({ action: "transform" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("continues when text does not match trigger", async () => {
|
||||||
|
const { emit, exec } = setup(gitSuccess);
|
||||||
|
const result = await emit("explain this function");
|
||||||
|
expect(result).toEqual({ action: "continue" });
|
||||||
|
expect(exec).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("continues when git diff is empty", async () => {
|
||||||
|
const { emit } = setup(gitEmpty);
|
||||||
|
const result = await emit("any changes?");
|
||||||
|
expect(result).toEqual({ action: "continue" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("continues when git fails", async () => {
|
||||||
|
const { emit } = setup(gitFail);
|
||||||
|
const result = await emit("show modified files");
|
||||||
|
expect(result).toEqual({ action: "continue" });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user