From 4ba6d9b2b962bdc5709a0edeab3b5b6bb9eb14a5 Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 9 Jun 2026 19:29:28 +0000 Subject: [PATCH] fix(tui): suppressed tiny-title worker output Stopped the tiny-title subprocess from inheriting stdout and stderr so native model runtime output cannot corrupt the interactive scrollback. Added a regression test for worker stdio configuration.\n\nFixes #2206 --- packages/coding-agent/CHANGELOG.md | 4 ++ .../coding-agent/src/tiny/title-client.ts | 9 ++-- .../test/tiny-title-generator.test.ts | 44 ++++++++++++++++++- 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5e0433e34..d281b5c85 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed local tiny-title worker stdout/stderr leaking raw native model output such as `` and cache/status lines into the interactive TUI scrollback ([#2206](https://github.com/can1357/oh-my-pi/issues/2206)). + ## [15.10.9] - 2026-06-09 ### Fixed diff --git a/packages/coding-agent/src/tiny/title-client.ts b/packages/coding-agent/src/tiny/title-client.ts index 6a05b85a6..d5479147c 100644 --- a/packages/coding-agent/src/tiny/title-client.ts +++ b/packages/coding-agent/src/tiny/title-client.ts @@ -122,7 +122,7 @@ function tinyWorkerSpawnCmd(): string[] { } interface SpawnedSubprocess { - proc: Subprocess<"ignore", "inherit", "inherit">; + proc: Subprocess<"ignore", "ignore", "ignore">; inbound: Set<(message: TinyTitleWorkerOutbound) => void>; errors: Set<(error: Error) => void>; /** @@ -147,10 +147,13 @@ export function createTinyTitleSubprocess(): SpawnedSubprocess { cmd: tinyWorkerSpawnCmd(), env: tinyWorkerEnv(), stdin: "ignore", - stdout: "inherit", - stderr: "inherit", + stdout: "ignore", + stderr: "ignore", serialization: "advanced", windowsHide: true, + // The worker is an implementation detail of the interactive TUI. Native + // model runtimes may print progress or decoded text directly; never let + // those bytes inherit the terminal and corrupt the chat scrollback. ipc(message) { for (const handler of inbound) handler(message as TinyTitleWorkerOutbound); }, diff --git a/packages/coding-agent/test/tiny-title-generator.test.ts b/packages/coding-agent/test/tiny-title-generator.test.ts index 0cb8f4c24..17d728a64 100644 --- a/packages/coding-agent/test/tiny-title-generator.test.ts +++ b/packages/coding-agent/test/tiny-title-generator.test.ts @@ -20,12 +20,13 @@ import { TINY_TITLE_MODEL_OPTIONS, TINY_TITLE_MODEL_VALUES, } from "@oh-my-pi/pi-coding-agent/tiny/models"; -import { tinyTitleClient } from "@oh-my-pi/pi-coding-agent/tiny/title-client"; +import { createTinyTitleSubprocess, tinyTitleClient } from "@oh-my-pi/pi-coding-agent/tiny/title-client"; import { generateSessionTitle, raceFirstNonNull, TITLE_LOCAL_FALLBACK_DELAY_MS, } from "@oh-my-pi/pi-coding-agent/utils/title-generator"; +import type { Subprocess } from "bun"; async function flushMicrotasks(turns = 4): Promise { for (let i = 0; i < turns; i += 1) await Promise.resolve(); @@ -60,6 +61,33 @@ function createRegistry(model: Model) { } as never; } +type TinyWorkerSpawnOptions = Bun.SpawnOptions.SpawnOptions<"ignore", "ignore", "ignore">; + +type TinyWorkerSpawnCall = { + options: TinyWorkerSpawnOptions & { cmd: string[] }; +}; + +function createTinyWorkerSpawnMock(calls: TinyWorkerSpawnCall[]) { + function mockSpawn(options: TinyWorkerSpawnOptions & { cmd: string[] }): Subprocess<"ignore", "ignore", "ignore">; + function mockSpawn(cmd: string[], options?: TinyWorkerSpawnOptions): Subprocess<"ignore", "ignore", "ignore">; + function mockSpawn( + first: string[] | (TinyWorkerSpawnOptions & { cmd: string[] }), + second?: TinyWorkerSpawnOptions, + ): Subprocess<"ignore", "ignore", "ignore"> { + const options = Array.isArray(first) ? { ...(second ?? {}), cmd: first } : first; + calls.push({ options }); + return { + pid: 12345, + send: () => undefined, + kill: () => true, + unref: () => undefined, + exited: Promise.resolve(0), + } as unknown as Subprocess<"ignore", "ignore", "ignore">; + } + + return mockSpawn; +} + function mockOnlineTitle(title: string | null) { return vi.spyOn(ai, "completeSimple").mockResolvedValue({ stopReason: "stop", @@ -285,6 +313,20 @@ describe("tiny title generator routing", () => { }); }); +describe("tiny title subprocess", () => { + it("does not inherit worker output into the interactive terminal", async () => { + const calls: TinyWorkerSpawnCall[] = []; + vi.spyOn(Bun, "spawn").mockImplementation(createTinyWorkerSpawnMock(calls)); + + const worker = createTinyTitleSubprocess(); + + expect(calls).toHaveLength(1); + expect(calls[0]?.options.stdout).toBe("ignore"); + expect(calls[0]?.options.stderr).toBe("ignore"); + await worker.proc.exited; + }); +}); + describe("providers.tinyModel schema", () => { it("keeps enum values and UI options in sync with the tiny model registry", () => { expect(getEnumValues("providers.tinyModel")).toEqual([...TINY_TITLE_MODEL_VALUES]);