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
This commit is contained in:
roboomp
2026-06-09 19:29:28 +00:00
parent 7207929c72
commit 4ba6d9b2b9
3 changed files with 53 additions and 4 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed local tiny-title worker stdout/stderr leaking raw native model output such as `</title>` 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
@@ -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);
},
@@ -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<void> {
for (let i = 0; i < turns; i += 1) await Promise.resolve();
@@ -60,6 +61,33 @@ function createRegistry(model: Model<Api>) {
} 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]);