173 lines
6.8 KiB
TypeScript
173 lines
6.8 KiB
TypeScript
import { afterEach, describe, expect, test } from "bun:test"
|
|
import { Deferred, Effect, Fiber, Layer, Stream } from "effect"
|
|
import * as Sink from "effect/Sink"
|
|
import * as TestClock from "effect/testing/TestClock"
|
|
import { ChildProcessSpawner } from "effect/unstable/process"
|
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
|
import { CommandTimeout } from "@/kilocode/command-timeout"
|
|
import { ShellTool } from "@/tool/shell"
|
|
import { Plugin } from "@/plugin"
|
|
import { Truncate } from "@/tool/truncate"
|
|
import { Config } from "@/config/config"
|
|
import { Agent } from "@/agent/agent"
|
|
import { Shell } from "@/shell/shell"
|
|
import { MessageID, SessionID } from "@/session/schema"
|
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
|
import { testEffect } from "../lib/effect"
|
|
|
|
const max = process.env.KILO_COMMAND_TIMEOUT_MAX_MS
|
|
const msg = process.env.KILO_COMMAND_TIMEOUT_MAX_MS_MESSAGE
|
|
const encoder = new TextEncoder()
|
|
const it = testEffect(Layer.empty)
|
|
const shell = testEffect(
|
|
Layer.mergeAll(
|
|
CrossSpawnSpawner.defaultLayer,
|
|
AppFileSystem.defaultLayer,
|
|
Plugin.defaultLayer,
|
|
Truncate.defaultLayer,
|
|
Config.defaultLayer,
|
|
Agent.defaultLayer,
|
|
RuntimeFlags.defaultLayer,
|
|
),
|
|
)
|
|
|
|
function handle(input: {
|
|
exit?: ChildProcessSpawner.ChildProcessHandle["exitCode"]
|
|
stdout?: ChildProcessSpawner.ChildProcessHandle["stdout"]
|
|
stderr?: ChildProcessSpawner.ChildProcessHandle["stderr"]
|
|
kill?: ChildProcessSpawner.ChildProcessHandle["kill"]
|
|
}) {
|
|
return ChildProcessSpawner.makeHandle({
|
|
pid: ChildProcessSpawner.ProcessId(0),
|
|
exitCode: input.exit ?? Effect.succeed(ChildProcessSpawner.ExitCode(0)),
|
|
isRunning: Effect.succeed(true),
|
|
kill: input.kill ?? (() => Effect.void),
|
|
stdin: Sink.drain,
|
|
stdout: input.stdout ?? Stream.empty,
|
|
stderr: input.stderr ?? Stream.empty,
|
|
all: Stream.empty,
|
|
getInputFd: () => Sink.drain,
|
|
getOutputFd: () => Stream.empty,
|
|
unref: Effect.succeed(Effect.void),
|
|
})
|
|
}
|
|
|
|
afterEach(() => {
|
|
if (max === undefined) delete process.env.KILO_COMMAND_TIMEOUT_MAX_MS
|
|
else process.env.KILO_COMMAND_TIMEOUT_MAX_MS = max
|
|
if (msg === undefined) delete process.env.KILO_COMMAND_TIMEOUT_MAX_MS_MESSAGE
|
|
else process.env.KILO_COMMAND_TIMEOUT_MAX_MS_MESSAGE = msg
|
|
})
|
|
|
|
describe("CommandTimeout", () => {
|
|
// Pure policy coverage: no process or timer waits.
|
|
test("resolves hosted timeout policy", () => {
|
|
for (const value of [undefined, "0", "-1", "abc"]) {
|
|
if (value === undefined) delete process.env.KILO_COMMAND_TIMEOUT_MAX_MS
|
|
else process.env.KILO_COMMAND_TIMEOUT_MAX_MS = value
|
|
expect(CommandTimeout.env()).toBeUndefined()
|
|
}
|
|
|
|
process.env.KILO_COMMAND_TIMEOUT_MAX_MS = "250"
|
|
process.env.KILO_COMMAND_TIMEOUT_MAX_MS_MESSAGE = " You're running in a sandbox. "
|
|
|
|
expect(CommandTimeout.clamp(500)).toEqual({ timeout: 250, capped: true })
|
|
expect(CommandTimeout.clamp(250)).toEqual({ timeout: 250, capped: true })
|
|
expect(CommandTimeout.clamp(200)).toEqual({ timeout: 200, capped: false })
|
|
expect(CommandTimeout.duration(250)).toBe(250)
|
|
expect(CommandTimeout.duration(200)).toBe(300)
|
|
expect(CommandTimeout.env()).toEqual({ timeout: 250, capped: true })
|
|
expect(CommandTimeout.note({ timeout: 250, capped: true }, "shell command terminated")).toBe(
|
|
"shell command terminated after exceeding environment timeout 250 ms. You're running in a sandbox.",
|
|
)
|
|
})
|
|
|
|
// TestClock advances instantly; this does not wait 25 ms in real time.
|
|
it.effect("caps output draining at the exact environment deadline", () =>
|
|
Effect.gen(function* () {
|
|
process.env.KILO_COMMAND_TIMEOUT_MAX_MS = "25"
|
|
const state = { killed: false }
|
|
const child = handle({
|
|
exit: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
|
|
kill: () =>
|
|
Effect.sync(() => {
|
|
state.killed = true
|
|
}),
|
|
})
|
|
const fiber = yield* CommandTimeout.drain(child, Effect.never, "shell command terminated").pipe(Effect.forkChild)
|
|
yield* Effect.yieldNow
|
|
|
|
yield* TestClock.adjust("24 millis")
|
|
expect(state.killed).toBe(false)
|
|
yield* TestClock.adjust("1 millis")
|
|
expect(state.killed).toBe(true)
|
|
expect(yield* Fiber.join(fiber)).toBe("shell command terminated after exceeding environment timeout 25 ms.")
|
|
}),
|
|
)
|
|
|
|
// A fake spawner and Deferred prove concurrency without launching processes.
|
|
it.effect("captures command substitutions concurrently without stderr", () =>
|
|
Effect.gen(function* () {
|
|
process.env.KILO_COMMAND_TIMEOUT_MAX_MS = "500"
|
|
const gate = yield* Deferred.make<void>()
|
|
const state = { started: 0 }
|
|
const spawner = ChildProcessSpawner.make(() =>
|
|
Effect.sync(() => {
|
|
state.started++
|
|
const text = state.started === 1 ? "first" : "second"
|
|
const wait = Stream.fromEffect(Deferred.await(gate))
|
|
return handle({
|
|
exit: Deferred.await(gate).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))),
|
|
stdout: wait.pipe(Stream.flatMap(() => Stream.make(encoder.encode(text)))),
|
|
stderr: Stream.make(encoder.encode("warning")),
|
|
})
|
|
}),
|
|
)
|
|
const fiber = yield* CommandTimeout.texts(["one", "two"], "sh").pipe(
|
|
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
|
|
Effect.forkChild,
|
|
)
|
|
yield* Effect.yieldNow
|
|
|
|
expect(state.started).toBe(2)
|
|
yield* Deferred.succeed(gate, undefined)
|
|
expect(yield* Fiber.join(fiber)).toEqual(["first", "second"])
|
|
}),
|
|
)
|
|
})
|
|
|
|
// This is the only real process and wall-clock timeout in this suite.
|
|
shell.instance(
|
|
"caps shell tool timeouts with hosted guidance",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
process.env.KILO_COMMAND_TIMEOUT_MAX_MS = "500"
|
|
process.env.KILO_COMMAND_TIMEOUT_MAX_MS_MESSAGE = "You're running in a sandbox."
|
|
const name = Shell.name(Shell.acceptable())
|
|
const exe = JSON.stringify(process.execPath)
|
|
const script = "process.stdout.write(String.fromCharCode(115,116,97,114,116,101,100));setTimeout(()=>{},30000)"
|
|
const command = name === "powershell" || name === "pwsh" ? `& ${exe} -e '${script}'` : `${exe} -e '${script}'`
|
|
const info = yield* ShellTool
|
|
const tool = yield* info.init()
|
|
const result = yield* tool.execute(
|
|
{ command, description: "Hosted timeout" },
|
|
{
|
|
sessionID: SessionID.make("ses_test"),
|
|
messageID: MessageID.make("msg_test"),
|
|
callID: "",
|
|
agent: "code",
|
|
abort: AbortSignal.any([]),
|
|
messages: [],
|
|
metadata: () => Effect.void,
|
|
ask: () => Effect.void,
|
|
},
|
|
)
|
|
|
|
expect(result.output).toContain("environment timeout 500 ms")
|
|
expect(result.output).toContain("You're running in a sandbox.")
|
|
expect(result.output).not.toContain("retry with a larger timeout")
|
|
}),
|
|
15_000,
|
|
)
|