test(cli): validate PTY across release targets

This commit is contained in:
marius-kilocode
2026-08-24 13:46:27 +02:00
parent e2186def65
commit a35585ad9d
8 changed files with 333 additions and 82 deletions
+7
View File
@@ -211,10 +211,12 @@ jobs:
export XDG_CACHE_HOME="$root/cache"
export XDG_CONFIG_HOME="$root/config"
export XDG_STATE_HOME="$root/state"
export KILO_PTY_SMOKE=1 # kilocode_change
export KILO_DISABLE_MODELS_FETCH=1
export KILO_DISABLE_PROJECT_CONFIG=1
export KILO_CONFIG_CONTENT='{"enabled_providers":["anthropic"]}'
export ANTHROPIC_API_KEY=dummy
"$binary" --pure __pty-smoke # kilocode_change
"$binary" --pure models anthropic | grep -q '^anthropic/'
)
}
@@ -247,10 +249,12 @@ jobs:
export XDG_CACHE_HOME="$root/cache"
export XDG_CONFIG_HOME="$root/config"
export XDG_STATE_HOME="$root/state"
export KILO_PTY_SMOKE=1 # kilocode_change
export KILO_DISABLE_MODELS_FETCH=1
export KILO_DISABLE_PROJECT_CONFIG=1
export KILO_CONFIG_CONTENT='\''{"enabled_providers":["anthropic"]}'\''
export ANTHROPIC_API_KEY=dummy
"$binary" --pure __pty-smoke # kilocode_change
"$binary" --pure models anthropic | grep -q "^anthropic/"
'
@@ -307,10 +311,13 @@ jobs:
$env:XDG_CACHE_HOME = Join-Path $root "cache"
$env:XDG_CONFIG_HOME = Join-Path $root "config"
$env:XDG_STATE_HOME = Join-Path $root "state"
$env:KILO_PTY_SMOKE = "1" # kilocode_change
$env:KILO_DISABLE_MODELS_FETCH = "1"
$env:KILO_DISABLE_PROJECT_CONFIG = "1"
$env:KILO_CONFIG_CONTENT = '{"enabled_providers":["anthropic"]}'
$env:ANTHROPIC_API_KEY = "dummy"
& $binary --pure __pty-smoke # kilocode_change
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } # kilocode_change
$output = & $binary --pure models anthropic
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
if (-not ($output -match "(?m)^anthropic/")) {
+16 -4
View File
@@ -61,14 +61,14 @@ jobs:
run: |
if [ "$GENERAL" != "true" ]; then
echo 'general=false' >> "$GITHUB_OUTPUT"
echo 'settings=[{"os":"linux","index":1,"total":1,"host":"blacksmith-4vcpu-ubuntu-2404","run":false,"packages":false}]' >> "$GITHUB_OUTPUT"
echo 'settings=[{"os":"linux","index":1,"total":1,"host":"blacksmith-4vcpu-ubuntu-2404","run":false,"packages":false,"pty":false}]' >> "$GITHUB_OUTPUT"
exit 0
fi
echo 'general=true' >> "$GITHUB_OUTPUT"
# kilocode_change - Windows runs 6 shards at KILO_TEST_CONCURRENCY=2. Measured: a
# 5-shard matrix was worse on both wall-clock (slowest job 9.3m vs 6.7m) and machine
# minutes (43 vs 37) — with 2 workers per shard, packing more work per shard loses.
echo 'settings=[{"os":"linux","index":1,"total":2,"host":"blacksmith-4vcpu-ubuntu-2404","run":true,"packages":true},{"os":"linux","index":2,"total":2,"host":"blacksmith-4vcpu-ubuntu-2404","run":true,"packages":false},{"os":"macos","index":1,"total":1,"host":"macos-15","run":true,"packages":true},{"os":"windows","index":1,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":true},{"os":"windows","index":2,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false},{"os":"windows","index":3,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false},{"os":"windows","index":4,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false},{"os":"windows","index":5,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false},{"os":"windows","index":6,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false}]' >> "$GITHUB_OUTPUT"
echo 'settings=[{"os":"linux","index":1,"total":2,"host":"blacksmith-4vcpu-ubuntu-2404","run":true,"packages":true,"pty":true},{"os":"linux","index":2,"total":2,"host":"blacksmith-4vcpu-ubuntu-2404","run":true,"packages":false,"pty":false},{"os":"macos","index":1,"total":1,"host":"macos-15","run":true,"packages":true,"pty":true},{"os":"windows","index":1,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":true,"pty":true},{"os":"windows","index":2,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false,"pty":false},{"os":"windows","index":3,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false,"pty":false},{"os":"windows","index":4,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false,"pty":false},{"os":"windows","index":5,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false,"pty":false},{"os":"windows","index":6,"total":6,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false,"pty":false}]' >> "$GITHUB_OUTPUT"
# kilocode_change end
unit:
# kilocode_change start
@@ -118,7 +118,6 @@ jobs:
}
Write-Host "Defender path exclusions applied: $applied"
# kilocode_change end
- name: Checkout repository
if: matrix.settings.run # kilocode_change
uses: actions/checkout@v6 # kilocode_change
@@ -187,8 +186,21 @@ jobs:
run: bun turbo test:ci --output-logs=errors-only --log-order=grouped --log-prefix=task --filter='!@kilocode/cli' --filter='!@kilocode/kilo-jetbrains'
env:
KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: "true" # kilocode_change - non-CLI tests use the watcher-free unit-test profile
# kilocode_change end
# Keep this outside the CLI shard and OS profiles so one real PTY contract
# always runs on Linux, macOS, and Windows.
- name: Run cross-platform PTY service tests
if: matrix.settings.run && matrix.settings.pty
working-directory: packages/core
run: bun test test/kilocode/pty-platform.test.ts --timeout 60000
- name: Run cross-platform PTY route tests
if: matrix.settings.run && matrix.settings.pty
working-directory: packages/opencode
run: |
bun test test/server/httpapi-pty.test.ts --test-name-pattern "serves Agent Manager regular terminal" --timeout 60000
bun test test/server/httpapi-v2-pty.test.ts --test-name-pattern "serves Agent Manager script terminal" --timeout 60000
# kilocode_change end
# kilocode_change start - ensure the Darwin profile cannot suppress its own validation
- name: Validate Darwin CLI test profile
if: matrix.settings.run && matrix.settings.os == 'macos'
+71
View File
@@ -0,0 +1,71 @@
import { Shell } from "../../shell"
import { KiloPtyTermination } from "./termination"
import { spawn } from "#pty"
const TIMEOUT = 15_000
export async function smoke() {
const proc = spawn(Shell.preferred(), [], {
name: "xterm-256color",
cwd: process.cwd(),
env: { ...process.env, TERM: "xterm-256color", KILO_TERMINAL: "1" } as Record<string, string>,
cols: 80,
rows: 24,
})
const state = { output: "", exited: false }
const output = Promise.withResolvers<void>()
const exited = Promise.withResolvers<number>()
const data = proc.onData((chunk) => {
state.output += chunk
if (state.output.includes("KILO_PTY_READY")) output.resolve()
})
const exit = proc.onExit((event) => {
state.exited = true
exited.resolve(event.exitCode)
})
const timeout = AbortSignal.timeout(TIMEOUT)
try {
proc.resize(100, 40)
proc.write("echo KILO_PTY_READY\r")
await Promise.race([
output.promise,
new Promise<never>((_, reject) =>
timeout.addEventListener(
"abort",
() => reject(new Error(`PTY produced no output within ${TIMEOUT}ms: ${JSON.stringify(state.output)}`)),
{ once: true },
),
),
])
proc.write("exit 7\r")
const code = await Promise.race([
exited.promise,
new Promise<never>((_, reject) =>
timeout.addEventListener("abort", () => reject(new Error(`PTY did not exit within ${TIMEOUT}ms`)), {
once: true,
}),
),
])
if (code !== 7) throw new Error(`PTY exited ${code}, expected 7`)
} finally {
data.dispose()
exit.dispose()
if (!state.exited) proc.kill()
}
const active = spawn(Shell.preferred(), [], {
name: "xterm-256color",
cwd: process.cwd(),
env: process.env as Record<string, string>,
})
let stopped = false
try {
await KiloPtyTermination.terminate(active)
stopped = true
} finally {
if (!stopped) active.kill()
}
}
export * as PtySmoke from "./smoke"
@@ -0,0 +1,76 @@
import { describe, expect } from "bun:test"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Config } from "@opencode-ai/core/config"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { Pty } from "@opencode-ai/core/pty"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Deferred, Effect, Layer, Queue } from "effect"
import os from "node:os"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
const directory = os.tmpdir()
const layer = AppNodeBuilder.build(LayerNode.group([Pty.node, EventV2.node]), [
[Config.node, Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })],
[
Location.node,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
],
])
const it = testEffect(layer)
const attach = Effect.fn("PtyPlatformTest.attach")(function* (id: Pty.Info["id"]) {
const pty = yield* Pty.Service
const output = yield* Queue.unbounded<string>()
const ended = yield* Deferred.make<{ exitCode?: number }>()
const attachment = yield* pty.attach(id, {
onData: (data) => Queue.offerUnsafe(output, data),
onEnd: (event) => Deferred.doneUnsafe(ended, Effect.succeed(event)),
})
attachment.activate()
return { attachment, output, ended }
})
describe("cross-platform PTY", () => {
it.live("starts the default shell and exits from input", () =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const info = yield* Effect.acquireRelease(pty.create({ cwd: directory }), (item) =>
pty.remove(item.id).pipe(Effect.ignore),
)
const terminal = yield* attach(info.id)
terminal.attachment.write("exit\r")
expect(yield* Deferred.await(terminal.ended).pipe(Effect.timeout("15 seconds"))).toEqual({ exitCode: 0 })
}),
)
it.live("terminates a spawned process tree", () =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const source = [
'const child = Bun.spawn([process.execPath, "-e", "setInterval(() => {}, 1000)"], {',
' stdin: "ignore", stdout: "ignore", stderr: "ignore", windowsHide: true,',
"})",
"process.stdout.write(`CHILD:${child.pid}\\n`)",
"setInterval(() => {}, 1000)",
].join("\n")
const info = yield* Effect.acquireRelease(
pty.create({ command: process.execPath, args: ["-e", source], cwd: directory }),
(item) => pty.remove(item.id).pipe(Effect.ignore),
)
const terminal = yield* attach(info.id)
const output = yield* Effect.gen(function* () {
let text = ""
while (!text.includes("CHILD:")) text += yield* Queue.take(terminal.output)
return text
}).pipe(Effect.timeout("15 seconds"))
const pid = Number(output.match(/CHILD:(\d+)/)?.[1])
expect(pid).toBeGreaterThan(0)
yield* pty.remove(info.id)
expect(() => process.kill(pid, 0)).toThrow()
}),
)
})
@@ -0,0 +1,11 @@
import { cmd } from "@/cli/cmd/cmd"
export const PtySmokeCommand = cmd({
command: "__pty-smoke",
describe: false,
async handler() {
if (process.env.KILO_PTY_SMOKE !== "1") throw new Error("PTY smoke command is release-only")
const { PtySmoke } = await import("@opencode-ai/core/kilocode/pty/smoke")
await PtySmoke.smoke()
},
})
@@ -12,6 +12,7 @@ import { DevSetupCommand, DevAliasCommand } from "@/kilocode/cli/dev-setup"
import { RemoteCommand } from "@/cli/cmd/remote"
import { ConfigCommand as ConfigCLICommand } from "@/cli/cmd/config"
import { WorktreeCommand } from "@/kilocode/cli/cmd/worktree"
import { PtySmokeCommand } from "@/kilocode/cli/cmd/pty-smoke"
const log = Log.create({ service: "kilocode.cli" })
@@ -58,6 +59,7 @@ export namespace KiloCli {
.command(DaemonCommand)
.command(ConfigCLICommand)
.command(WorktreeCommand)
if (process.env.KILO_PTY_SMOKE === "1") cli.command(PtySmokeCommand)
if (InstallationBuildKind !== "release") cli.command(DevSetupCommand).command(DevAliasCommand)
// Safe self-reference: `cli` is a typed parameter and yargs `.command()` returns the same
// instance, so the help command can resolve the fully-built root at handler time. This also
@@ -269,48 +269,69 @@ describe("pty HttpApi bridge", () => {
message: `PTY session not found: ${missingID}`,
})
})
;(process.platform === "win32" ? effectIt.live.skip : effectIt.live)(
"serves PTY websocket output and input through Effect routes",
() =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true, config: { formatter: false, lsp: false } })
const created = yield* HttpClientRequest.post(PtyPaths.create).pipe(
directoryHeader(dir),
HttpClientRequest.bodyJson({ command: "/bin/cat", title: "websocket" }),
Effect.flatMap(HttpClient.execute),
// kilocode_change start - portable coverage for the exact legacy routes used by regular Agent Manager terminals
effectIt.live("serves Agent Manager regular terminal create, resize, input, output, and remove routes", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true, config: { formatter: false, lsp: false } })
const child = [
'let input = ""',
"process.stdout.write(`READY:${process.stdout.isTTY}:${process.stdout.columns}x${process.stdout.rows}\\n`)",
'process.stdin.setEncoding("utf8")',
'process.stdin.on("data", (chunk) => {',
" input += chunk",
' if (input.includes("PING")) process.stdout.write(`PONG:${process.stdout.columns}x${process.stdout.rows}\\n`)',
"})",
].join("\n")
const created = yield* HttpClientRequest.post(PtyPaths.create).pipe(
directoryHeader(dir),
HttpClientRequest.bodyJson({
command: process.execPath,
args: ["-e", child],
title: "websocket",
size: { cols: 80, rows: 24 },
}),
Effect.flatMap(HttpClient.execute),
)
expect(created.status).toBe(200)
const info = yield* Schema.decodeUnknownEffect(Pty.Info)(yield* created.json)
const socket = yield* Socket.makeWebSocket(
`${(yield* serverUrl()).replace(/^http/, "ws")}${PtyPaths.connect.replace(":ptyID", info.id)}?cursor=0&directory=${encodeURIComponent(dir)}`,
{ closeCodeIsError: () => false },
)
const messages = yield* Queue.unbounded<string>()
yield* socket
.runRaw((message) =>
Queue.offer(messages, typeof message === "string" ? message : new TextDecoder().decode(message)),
)
expect(created.status).toBe(200)
const info = yield* Schema.decodeUnknownEffect(Pty.Info)(yield* created.json)
.pipe(Effect.catch(() => Effect.void))
.pipe(Effect.forkScoped)
const write = yield* socket.writer
const socket = yield* Socket.makeWebSocket(
`${(yield* serverUrl()).replace(/^http/, "ws")}${PtyPaths.connect.replace(":ptyID", info.id)}?cursor=-1&directory=${encodeURIComponent(dir)}`,
{ closeCodeIsError: () => false },
)
const messages = yield* Queue.unbounded<string>()
yield* socket
.runRaw((message) =>
Queue.offer(messages, typeof message === "string" ? message : new TextDecoder().decode(message)),
)
.pipe(Effect.catch(() => Effect.void))
.pipe(Effect.forkScoped)
const write = yield* socket.writer
const takeUntil = (expected: string, seen = ""): Effect.Effect<string, unknown> =>
Effect.gen(function* () {
const next = seen + (yield* Queue.take(messages).pipe(Effect.timeout("5 seconds")))
if (next.includes(expected)) return next
return yield* takeUntil(expected, next)
})
const takeUntil = (expected: string, seen = ""): Effect.Effect<string, unknown> =>
Effect.gen(function* () {
const next = seen + (yield* Queue.take(messages).pipe(Effect.timeout("5 seconds")))
if (next.includes(expected)) return next
return yield* takeUntil(expected, next)
})
expect(yield* takeUntil("READY:")).toContain("READY:true:80x24")
const updated = yield* HttpClientRequest.put(PtyPaths.update.replace(":ptyID", info.id)).pipe(
directoryHeader(dir),
HttpClientRequest.bodyJson({ size: { cols: 100, rows: 40 } }),
Effect.flatMap(HttpClient.execute),
)
expect(updated.status).toBe(200)
yield* write("ping-route\n")
expect(yield* takeUntil("ping-route")).toContain("ping-route")
yield* write(new Socket.CloseEvent(1000, "done")).pipe(Effect.catch(() => Effect.void))
yield* write("PING\r")
expect(yield* takeUntil("PONG:100x40")).toContain("PONG:100x40")
const removed = yield* HttpClientRequest.delete(PtyPaths.remove.replace(":ptyID", info.id)).pipe(
directoryHeader(dir),
HttpClient.execute,
)
expect(removed.status).toBe(200)
}),
const removed = yield* HttpClientRequest.delete(PtyPaths.remove.replace(":ptyID", info.id)).pipe(
directoryHeader(dir),
HttpClient.execute,
)
expect(removed.status).toBe(200)
}),
)
// kilocode_change end
})
@@ -134,51 +134,102 @@ describe("v2 pty HttpApi", () => {
await request(`/api/pty/${info.id}`, tmp.path, { method: "DELETE" })
}
})
;(process.platform === "win32" ? effectIt.live.skip : effectIt.live)(
"serves PTY websocket output and input through the canonical route",
() =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true, config: { formatter: false, lsp: false } })
const created = yield* HttpClientRequest.post("/api/pty").pipe(
directoryHeader(dir),
HttpClientRequest.bodyJson({ command: "/bin/cat", title: "v2-websocket" }),
Effect.flatMap(HttpClient.execute),
)
expect(created.status).toBe(200)
const body = yield* Schema.decodeUnknownEffect(Location.response(Pty.Info))(yield* created.json)
const info = body.data
// kilocode_change start - portable live PTY coverage on Linux, macOS, and Windows CI
effectIt.live("serves Agent Manager script terminal create, resize, input, output, exit, and remove routes", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true, config: { formatter: false, lsp: false } })
const child = [
'const state = { input: "", pong: false }',
"process.stdout.write(`READY:${process.stdout.isTTY}:${process.stdout.columns}x${process.stdout.rows}\\n`)",
'process.stdin.setEncoding("utf8")',
'process.stdin.on("data", (chunk) => {',
" state.input += chunk",
' if (!state.pong && state.input.includes("PING")) {',
" state.pong = true",
" process.stdout.write(`PONG:${process.stdout.columns}x${process.stdout.rows}\\n`)",
" }",
' if (state.input.includes("EXIT")) process.exit(7)',
"})",
].join("\n")
const created = yield* HttpClientRequest.post("/api/pty").pipe(
directoryHeader(dir),
HttpClientRequest.bodyJson({
command: process.execPath,
args: ["-e", child],
title: "v2-websocket",
size: { cols: 80, rows: 24 },
}),
Effect.flatMap(HttpClient.execute),
)
expect(created.status).toBe(200)
const body = yield* Schema.decodeUnknownEffect(Location.response(Pty.Info))(yield* created.json)
const info = body.data
const socket = yield* Socket.makeWebSocket(
`${(yield* serverUrl()).replace(/^http/, "ws")}/api/pty/${info.id}/connect?cursor=-1&location[directory]=${encodeURIComponent(dir)}`,
{ closeCodeIsError: () => false },
const socket = yield* Socket.makeWebSocket(
`${(yield* serverUrl()).replace(/^http/, "ws")}/api/pty/${info.id}/connect?cursor=0&location[directory]=${encodeURIComponent(dir)}`,
{ closeCodeIsError: () => false },
)
const messages = yield* Queue.unbounded<string>()
yield* socket
.runRaw((message) =>
Queue.offer(messages, typeof message === "string" ? message : new TextDecoder().decode(message)),
)
const messages = yield* Queue.unbounded<string>()
yield* socket
.runRaw((message) =>
Queue.offer(messages, typeof message === "string" ? message : new TextDecoder().decode(message)),
.pipe(Effect.catch(() => Effect.void))
.pipe(Effect.forkScoped)
const write = yield* socket.writer
const takeUntil = (expected: string, seen = ""): Effect.Effect<string, unknown> =>
Effect.gen(function* () {
const next =
seen +
(yield* Queue.take(messages).pipe(
Effect.timeoutOrElse({
duration: "5 seconds",
orElse: () =>
Effect.fail(
new Error(
`PTY output did not contain ${JSON.stringify(expected)}, received ${JSON.stringify(seen)}`,
),
),
}),
))
if (next.includes(expected)) return next
return yield* takeUntil(expected, next)
})
expect(yield* takeUntil("READY:")).toContain("READY:true:80x24")
const resized = yield* HttpClientRequest.put(`/api/pty/${info.id}`).pipe(
directoryHeader(dir),
HttpClientRequest.bodyJson({ size: { cols: 100, rows: 40 } }),
Effect.flatMap(HttpClient.execute),
)
expect(resized.status).toBe(200)
yield* write("PING\r")
expect(yield* takeUntil("PONG:100x40")).toContain("PONG:100x40")
yield* write("EXIT\r")
const exit = yield* Effect.gen(function* () {
while (true) {
const response = yield* HttpClientRequest.get(`/api/pty/${info.id}`).pipe(
directoryHeader(dir),
HttpClient.execute,
)
.pipe(Effect.catch(() => Effect.void))
.pipe(Effect.forkScoped)
const write = yield* socket.writer
expect(response.status).toBe(200)
const data = (yield* Schema.decodeUnknownEffect(Location.response(Pty.Info))(yield* response.json)).data
if (data.status === "exited") return data
yield* Effect.sleep("20 millis")
}
}).pipe(Effect.timeout("5 seconds"))
expect(exit).toMatchObject({ status: "exited", exitCode: 7 })
const takeUntil = (expected: string, seen = ""): Effect.Effect<string, unknown> =>
Effect.gen(function* () {
const next = seen + (yield* Queue.take(messages).pipe(Effect.timeout("5 seconds")))
if (next.includes(expected)) return next
return yield* takeUntil(expected, next)
})
yield* write("ping-v2\n")
expect(yield* takeUntil("ping-v2")).toContain("ping-v2")
yield* write(new Socket.CloseEvent(1000, "done")).pipe(Effect.catch(() => Effect.void))
const removed = yield* HttpClientRequest.delete(`/api/pty/${info.id}`).pipe(
directoryHeader(dir),
HttpClient.execute,
)
expect(removed.status).toBe(204)
}),
const removed = yield* HttpClientRequest.delete(`/api/pty/${info.id}`).pipe(
directoryHeader(dir),
HttpClient.execute,
)
expect(removed.status).toBe(204)
}),
)
// kilocode_change end
;(process.platform === "win32" ? effectIt.live.skip : effectIt.live)(
"applies plugin shell environment before forced PTY values",
() =>