diff --git a/.changeset/pty-early-output.md b/.changeset/pty-early-output.md new file mode 100644 index 000000000000..f7ebb4687f9f --- /dev/null +++ b/.changeset/pty-early-output.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Keep output and exit status of short-lived terminal commands that finish before the terminal session attaches its listeners diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index aa0293b7d9f0..246b973cbe78 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -59,7 +59,8 @@ jobs: needs: check-paths if: needs.check-paths.outputs.matched == 'true' name: Visual Regression (kilo-ui) # kilocode_change - runs-on: ${{ github.repository == 'Kilo-Org/kilocode' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} # kilocode_change + # kilocode_change: temporary GitHub-hosted runner while Blacksmith apt mirror connectivity is broken, see Blacksmith report for run 34574732611 + runs-on: ubuntu-24.04 # kilocode_change timeout-minutes: 15 steps: @@ -221,7 +222,8 @@ jobs: needs: check-paths if: needs.check-paths.outputs.matched == 'true' name: Visual Regression (kilo-vscode webview) # kilocode_change - runs-on: ${{ github.repository == 'Kilo-Org/kilocode' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} # kilocode_change + # kilocode_change: temporary GitHub-hosted runner while Blacksmith apt mirror connectivity is broken, see Blacksmith report for run 34574732611 + runs-on: ubuntu-24.04 # kilocode_change timeout-minutes: 15 env: NODE_OPTIONS: --max-old-space-size=4096 diff --git a/bun.lock b/bun.lock index a14a29d1967a..39cfd155e32d 100644 --- a/bun.lock +++ b/bun.lock @@ -1003,25 +1003,26 @@ }, }, "trustedDependencies": [ - "esbuild", - "protobufjs", "web-tree-sitter", + "esbuild", "tree-sitter-bash", + "protobufjs", ], "patchedDependencies": { - "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", - "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", + "virtua@0.49.1": "patches/virtua@0.49.1.patch", + "mammoth@1.12.0": "patches/mammoth@1.12.0.patch", "@ff-labs/fff-bun@0.9.4": "patches/@ff-labs%2Ffff-bun@0.9.4.patch", - "pacote@21.5.1": "patches/pacote@21.5.1.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", + "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", + "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", + "pacote@21.5.1": "patches/pacote@21.5.1.patch", + "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch", "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch", - "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "solid-js@1.9.12": "patches/solid-js@1.9.12.patch", + "bun-pty@0.4.8": "patches/bun-pty@0.4.8.patch", "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", - "virtua@0.49.1": "patches/virtua@0.49.1.patch", - "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", - "mammoth@1.12.0": "patches/mammoth@1.12.0.patch", + "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", }, "overrides": { "@effect/platform-node-shared": "4.0.0-beta.74", diff --git a/package.json b/package.json index cfb9f0cbe703..17db0f6a1c7c 100644 --- a/package.json +++ b/package.json @@ -176,7 +176,8 @@ "pacote@21.5.1": "patches/pacote@21.5.1.patch", "mammoth@1.12.0": "patches/mammoth@1.12.0.patch", "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch", - "solid-js@1.9.12": "patches/solid-js@1.9.12.patch" + "solid-js@1.9.12": "patches/solid-js@1.9.12.patch", + "bun-pty@0.4.8": "patches/bun-pty@0.4.8.patch" }, "version": "7.6.2", "peerDependencies": {} diff --git a/packages/core/src/kilocode/pty/latch.ts b/packages/core/src/kilocode/pty/latch.ts new file mode 100644 index 000000000000..1152e1214e65 --- /dev/null +++ b/packages/core/src/kilocode/pty/latch.ts @@ -0,0 +1,40 @@ +import type { Disp, Exit, Proc } from "../../pty/pty" + +// bun-pty emits data and exit from its read loop and drops events that fire before a listener +// is attached. A short-lived child can exit in the gap between spawn and the Pty service +// registering its listeners, so buffer early events and replay them once a listener attaches. +// Replay runs in a microtask so the caller finishes wiring the session before it observes them. +function attach( + early: Disp, + buffer: T[], + subscribe: (listener: (event: T) => void) => Disp, + listener: (event: T) => void, +): Disp { + early.dispose() + const disp = subscribe(listener) + const state = { live: true } + queueMicrotask(() => { + if (!state.live) return + for (const event of buffer.splice(0)) listener(event) + }) + return { + dispose() { + state.live = false + disp.dispose() + }, + } +} + +export function latch(proc: Proc): Proc { + const data: string[] = [] + const exit: Exit[] = [] + const early = { + data: proc.onData((chunk) => data.push(chunk)), + exit: proc.onExit((event) => exit.push(event)), + } + return { + ...proc, + onData: (listener) => attach(early.data, data, (fn) => proc.onData(fn), listener), + onExit: (listener) => attach(early.exit, exit, (fn) => proc.onExit(fn), listener), + } +} diff --git a/packages/core/src/pty/pty.bun.ts b/packages/core/src/pty/pty.bun.ts index 1f8ce8e4546b..d67b6918e4f2 100644 --- a/packages/core/src/pty/pty.bun.ts +++ b/packages/core/src/pty/pty.bun.ts @@ -1,11 +1,14 @@ import { spawn as create } from "bun-pty" +import { latch } from "../kilocode/pty/latch" // kilocode_change import type { Opts, Proc } from "./pty" export type { Disp, Exit, Opts, Proc } from "./pty" export function spawn(file: string, args: string[], opts: Opts): Proc { const pty = create(file, args, opts) - return { + // kilocode_change start - bun-pty drops events emitted before listeners attach + return latch({ + // kilocode_change end pid: pty.pid, onData(listener) { return pty.onData(listener) @@ -22,5 +25,5 @@ export function spawn(file: string, args: string[], opts: Opts): Proc { kill(signal) { pty.kill(signal) }, - } + }) // kilocode_change } diff --git a/packages/core/test/kilocode/pty-latch.test.ts b/packages/core/test/kilocode/pty-latch.test.ts new file mode 100644 index 000000000000..6a1c1fd8a7df --- /dev/null +++ b/packages/core/test/kilocode/pty-latch.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from "bun:test" +import { spawn } from "../../src/pty/pty.bun" + +const run = process.platform === "win32" ? test.skip : test + +// bun-pty fires each event once from its read loop. Without the latch, a child that exits +// before the caller attaches listeners loses both its output and its exit (0/20 delivered). +run("replays output and exit to listeners attached after the child exited", async () => { + const proc = spawn("sh", ["-c", 'printf "early"; exit 7'], { + name: "xterm", + cwd: "/tmp", + env: { PATH: process.env.PATH ?? "" }, + }) + await Bun.sleep(300) + + const chunks: string[] = [] + const exit = Promise.withResolvers<{ exitCode: number }>() + proc.onData((chunk) => chunks.push(chunk)) + proc.onExit((event) => exit.resolve(event)) + + const timeout = Bun.sleep(3000).then(() => { + throw new Error("timed out waiting for replayed exit") + }) + expect(await Promise.race([exit.promise, timeout])).toEqual({ exitCode: 7 }) + expect(chunks.join("")).toContain("early") +}) + +run("does not replay to a listener disposed before the microtask runs", async () => { + const proc = spawn("sh", ["-c", "exit 0"], { name: "xterm", cwd: "/tmp", env: { PATH: process.env.PATH ?? "" } }) + await Bun.sleep(300) + + const seen: unknown[] = [] + proc.onExit((event) => seen.push(event)).dispose() + await Bun.sleep(50) + expect(seen).toEqual([]) +}) diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-with-changes-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-with-changes-chromium-linux.png index 82199c3bedce..af3bd72e5748 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-with-changes-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-with-changes-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:670be20ffe37bc4e565d22bbe5e2becb15fe6da8a48a98a41c8f608faf3059cb -size 52936 +oid sha256:003ea4d8d6412bbda7ad30c2658baf6d69f75232a0494e3d07f28793e4602f5e +size 53514 diff --git a/packages/tui/test/cli/tui/question-custom-answer.test.tsx b/packages/tui/test/cli/tui/question-custom-answer.test.tsx index 1a154d9bce38..f6f684e717ea 100644 --- a/packages/tui/test/cli/tui/question-custom-answer.test.tsx +++ b/packages/tui/test/cli/tui/question-custom-answer.test.tsx @@ -1,3 +1,4 @@ +// kilocode_change - new file /** @jsxImportSource @opentui/solid */ import { TextareaRenderable } from "@opentui/core" import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" @@ -12,9 +13,9 @@ import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { TestTuiContexts } from "../../fixture/tui-environment" import { createEventSource } from "../../fixture/tui-sdk" -async function wait(fn: () => boolean, timeout = 2000) { +async function wait(fn: () => boolean | Promise, timeout = 5000) { const start = Date.now() - while (!fn()) { + while (!(await fn())) { if (Date.now() - start > timeout) throw new Error("timed out waiting for condition") await Bun.sleep(10) } @@ -104,8 +105,11 @@ async function mount(input: { root: string; requests: { path: string; body: unkn } async function openCustomEditor(prompt: Awaited>) { - await prompt.app.renderOnce() - await Bun.sleep(50) + // The provider tree mounts the prompt asynchronously, so render until the options are on screen. + await wait(async () => { + await prompt.app.renderOnce() + return prompt.app.captureCharFrame().includes("Type your own answer") + }) await prompt.app.flush() prompt.app.mockInput.pressArrow("down") await prompt.app.flush() diff --git a/patches/bun-pty@0.4.8.patch b/patches/bun-pty@0.4.8.patch new file mode 100644 index 000000000000..1d22c351580c --- /dev/null +++ b/patches/bun-pty@0.4.8.patch @@ -0,0 +1,15 @@ +diff --git a/src/terminal.ts b/src/terminal.ts +index ec248d46a939f8a09cd669e853cefb126922c80a..3d23a1e4e63d439274588bf4dfb16f4c5c8539d3 100644 +--- a/src/terminal.ts ++++ b/src/terminal.ts +@@ -172,7 +172,9 @@ export class Terminal implements IPty { + if (this.handle < 0) throw new Error("PTY spawn failed"); + + this._pid = lib.symbols.bun_pty_get_pid(this.handle); +- this._startReadLoop(); ++ // Defer the first read so listeners attached right after spawn() see output ++ // and exit that the reader thread already queued while the child started. ++ queueMicrotask(() => this._startReadLoop()); + } + + /* ------------- accessors ------------- */