From 84989836f1f46c476eb34ced94da7ed38854e4a4 Mon Sep 17 00:00:00 2001 From: ooiuuii Date: Wed, 7 Oct 2026 02:23:11 -0700 Subject: [PATCH] fix(background-terminals): preserve shell-significant command whitespace --- extensions/background-terminals/index.ts | 4 +- .../background-terminals/index.test.ts | 117 ++++++++++++++++++ 2 files changed, 119 insertions(+), 2 deletions(-) diff --git a/extensions/background-terminals/index.ts b/extensions/background-terminals/index.ts index 7817340a4..9c756d5f1 100644 --- a/extensions/background-terminals/index.ts +++ b/extensions/background-terminals/index.ts @@ -382,8 +382,8 @@ export default function (pi: ExtensionAPI) { async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const manager = await getManager(); - const command = params.command.trim(); - if (!command) throw new Error("command must not be empty."); + const command = params.command; + if (!command.trim()) throw new Error("command must not be empty."); const cwd = path.resolve(ctx.cwd, params.working_dir ?? "."); if (!fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory()) { diff --git a/tests/extensions/background-terminals/index.test.ts b/tests/extensions/background-terminals/index.test.ts index ce5e77b29..481030df2 100644 --- a/tests/extensions/background-terminals/index.test.ts +++ b/tests/extensions/background-terminals/index.test.ts @@ -1,10 +1,12 @@ import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; import test from "node:test"; import type { ExtensionAPI, ExtensionContext, } from "@earendil-works/pi-coding-agent"; import backgroundTerminals from "../../../extensions/background-terminals/index.ts"; +import { webCapabilityDetail } from "../../../extensions/shared/web-observer-registry.ts"; type CapturedTool = { name: string; @@ -132,3 +134,118 @@ test("a successful start exposes lifecycle tools but a rejected start does not", } } }); + +test("bg_start preserves shell-significant trailing whitespace", { + skip: process.platform === "win32", + timeout: 20_000, +}, async () => { + const tools = new Map(); + const handlers = new Map< + string, + Array<(event: unknown, ctx: ExtensionContext) => unknown> + >(); + let active: string[] = []; + const pi = { + on(event: string, handler: unknown) { + handlers.set(event, [ + ...(handlers.get(event) ?? []), + handler as (event: unknown, ctx: ExtensionContext) => unknown, + ]); + }, + registerTool(tool: CapturedTool) { + tools.set(tool.name, tool); + active.push(tool.name); + }, + getActiveTools: () => [...active], + setActiveTools(names: string[]) { + active = [...names]; + }, + registerMessageRenderer() {}, + registerCommand() {}, + sendMessage() {}, + } as unknown as ExtensionAPI; + const scope = { getSessionId: () => "command-preservation" }; + const ctx = { + cwd: process.cwd(), + hasUI: false, + isIdle: () => false, + sessionManager: scope, + } as unknown as ExtensionContext; + backgroundTerminals(pi); + const emit = async (event: string) => { + for (const handler of handlers.get(event) ?? []) await handler({}, ctx); + }; + await emit("session_start"); + try { + const start = tools.get("bg_start"), + list = tools.get("bg_list"); + assert(start && list); + const before = await list.execute("before", {}, undefined, undefined, ctx); + await assert.rejects( + start.execute( + "blank", + { command: " \t\n ", title: "blank" }, + undefined, + undefined, + ctx, + ), + /command must not be empty/, + ); + assert.deepEqual( + await list.execute("after", {}, undefined, undefined, ctx), + before, + ); + for (const command of [ + "printf %s hello", + "printf %s hello" + String.fromCharCode(92) + " ", + ]) { + const control = spawnSync("/bin/sh", ["-c", command], { timeout: 5_000 }); + assert.ifError(control.error); + assert.equal(control.status, 0); + assert.equal( + control.stdout.toString(), + command.endsWith(" ") ? "hello " : "hello", + ); + const result = (await start.execute( + "start", + { command, title: "command preservation", timeout_seconds: 3 }, + undefined, + undefined, + ctx, + )) as { details: { id: string } }; + const deadline = Date.now() + 5_000; + let observed = webCapabilityDetail( + scope, + "background-terminals", + result.details.id, + ); + while ( + observed.status === "found" && + observed.detail.status === "running" && + Date.now() < deadline + ) { + await new Promise((resolve) => setTimeout(resolve, 10)); + observed = webCapabilityDetail( + scope, + "background-terminals", + result.details.id, + ); + } + assert.equal(observed.status, "found"); + if (observed.status !== "found") + assert.fail("missing registered terminal"); + const snapshot = observed.detail; + assert.equal(snapshot.kind, "background-terminals"); + if (snapshot.kind !== "background-terminals") + assert.fail("wrong capability kind"); + assert.equal(snapshot.status, "done"); + assert.equal(snapshot.exitCode, 0); + assert.equal(snapshot.stdout.totalBytes, control.stdout.length); + assert.deepEqual(Buffer.from(snapshot.stdout.text), control.stdout); + assert.equal(snapshot.stderr.text, ""); + assert.equal(snapshot.command, command); + } + } finally { + await emit("session_shutdown"); + } +});