mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
510 lines
19 KiB
TypeScript
510 lines
19 KiB
TypeScript
import { describe, expect, it } from "bun:test"
|
|
import type { KiloClient } from "@kilocode/sdk/v2/client"
|
|
import { ScriptTerminalManager, type ScriptTerminalView } from "../../src/agent-manager/ScriptTerminalManager"
|
|
import { buildScriptTerminalWsUrl } from "../../src/agent-manager/script-terminal-url"
|
|
import { RunScriptManager, type RunStatus } from "../../src/agent-manager/run/manager"
|
|
|
|
interface PtyInput {
|
|
location?: { directory?: string }
|
|
command?: string
|
|
args?: string[]
|
|
cwd?: string
|
|
env?: Record<string, string>
|
|
title?: string
|
|
}
|
|
|
|
interface PtyUpdate {
|
|
ptyID: string
|
|
location?: { directory?: string }
|
|
size?: { cols: number; rows: number }
|
|
}
|
|
|
|
interface PtyInfo {
|
|
id: string
|
|
title: string
|
|
command: string
|
|
args: string[]
|
|
cwd: string
|
|
status: "running" | "exited"
|
|
pid: number
|
|
exitCode?: number
|
|
}
|
|
|
|
interface PtyResponse {
|
|
data?: { location: { directory: string }; data: PtyInfo }
|
|
error?: unknown
|
|
}
|
|
|
|
function wait(): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, 0))
|
|
}
|
|
|
|
function deferred<T>() {
|
|
let resolve: (value: T) => void = () => undefined
|
|
const promise = new Promise<T>((next) => {
|
|
resolve = next
|
|
})
|
|
return { promise, resolve }
|
|
}
|
|
|
|
function info(status: PtyInfo["status"] = "running", exitCode?: number): PtyInfo {
|
|
return {
|
|
id: "pty-1",
|
|
title: "Run",
|
|
command: "bun",
|
|
args: ["run", "check"],
|
|
cwd: "/repo/worktree",
|
|
status,
|
|
pid: 42,
|
|
...(exitCode === undefined ? {} : { exitCode }),
|
|
}
|
|
}
|
|
|
|
function harness(opts?: {
|
|
create?: (input: PtyInput) => Promise<PtyResponse>
|
|
get?: () => Promise<PtyResponse>
|
|
remove?: () => Promise<{ data?: unknown; error?: unknown }>
|
|
}) {
|
|
const calls: { create: PtyInput[]; get: unknown[]; update: PtyUpdate[]; remove: unknown[] } = {
|
|
create: [],
|
|
get: [],
|
|
update: [],
|
|
remove: [],
|
|
}
|
|
const snapshots: ScriptTerminalView[][] = []
|
|
const closed: string[] = []
|
|
const logs: string[] = []
|
|
const client = {
|
|
v2: {
|
|
pty: {
|
|
create: async (input: PtyInput) => {
|
|
calls.create.push(input)
|
|
return opts?.create ? opts.create(input) : { data: { location: { directory: config.cwd }, data: info() } }
|
|
},
|
|
get: async (input: unknown) => {
|
|
calls.get.push(input)
|
|
return opts?.get ? opts.get() : { data: { location: { directory: config.cwd }, data: info() } }
|
|
},
|
|
update: async (input: PtyUpdate) => {
|
|
calls.update.push(input)
|
|
return { data: info() }
|
|
},
|
|
remove: async (input: unknown) => {
|
|
calls.remove.push(input)
|
|
return opts?.remove ? opts.remove() : { data: undefined }
|
|
},
|
|
},
|
|
},
|
|
} as unknown as KiloClient
|
|
const manager = new ScriptTerminalManager({
|
|
getClient: () => client,
|
|
getClientAsync: async () => client,
|
|
buildWsUrl: (ptyID, cwd) => `ws://127.0.0.1:4096/api/pty/${ptyID}/connect?location=${cwd}`,
|
|
getTerminalFont: () => ({ fontFamily: "Menlo", fontSize: 12 }),
|
|
emit: (terminals) => snapshots.push(terminals),
|
|
closed: (terminalId) => closed.push(terminalId),
|
|
log: (msg) => logs.push(msg),
|
|
})
|
|
return { manager, calls, snapshots, closed, logs }
|
|
}
|
|
|
|
const config = {
|
|
worktreeId: "wt-1",
|
|
command: "bun",
|
|
args: ["run", "check"],
|
|
cwd: "/repo/worktree",
|
|
env: { PATH: "/bin", WORKTREE_PATH: "/repo/worktree" },
|
|
}
|
|
|
|
describe("ScriptTerminalManager", () => {
|
|
it("blocks a script PTY create while its worktree is being removed", async () => {
|
|
const gate = deferred<PtyResponse>()
|
|
const ctx = harness({ create: async () => gate.promise })
|
|
const started = ctx.manager.start("setup", config, () => undefined)
|
|
await wait()
|
|
|
|
const blocked = ctx.manager.blockDirectory(config.cwd)
|
|
await expect(ctx.manager.start("run", config, () => undefined)).rejects.toThrow("directory is being removed")
|
|
gate.resolve({ data: { location: { directory: config.cwd }, data: info() } })
|
|
await started
|
|
const release = await blocked
|
|
await ctx.manager.closeDirectory(config.cwd)
|
|
release()
|
|
|
|
expect(ctx.calls.remove).toHaveLength(1)
|
|
})
|
|
|
|
it("creates a Run PTY with explicit command settings and a safe snapshot", async () => {
|
|
const ctx = harness()
|
|
const done: unknown[] = []
|
|
|
|
await ctx.manager.start("run", config, (exit) => done.push(exit))
|
|
|
|
expect(ctx.calls.create).toEqual([
|
|
{
|
|
location: { directory: "/repo/worktree" },
|
|
command: "bun",
|
|
args: ["run", "check"],
|
|
cwd: "/repo/worktree",
|
|
env: { PATH: "/bin", WORKTREE_PATH: "/repo/worktree" },
|
|
title: "Run",
|
|
},
|
|
])
|
|
expect(ctx.calls.get).toEqual([{ ptyID: "pty-1", location: { directory: "/repo/worktree" } }])
|
|
expect(ctx.snapshots.at(-1)).toEqual([
|
|
expect.objectContaining({
|
|
worktreeId: "wt-1",
|
|
kind: "run",
|
|
title: "Run",
|
|
state: "running",
|
|
font: { fontFamily: "Menlo", fontSize: 12 },
|
|
}),
|
|
])
|
|
expect(JSON.stringify(ctx.snapshots.at(-1))).not.toContain('"command"')
|
|
expect(JSON.stringify(ctx.snapshots.at(-1))).not.toContain('"env"')
|
|
expect(done).toEqual([])
|
|
})
|
|
|
|
it("normalizes the internal local Run key to a null external worktree id", async () => {
|
|
const ctx = harness()
|
|
|
|
await ctx.manager.start("run", { ...config, worktreeId: "local", cwd: "/repo" }, () => undefined)
|
|
|
|
expect(ctx.snapshots.at(-1)?.[0]?.worktreeId).toBeNull()
|
|
})
|
|
|
|
it("keeps identical worktree script terminals independent across projects", async () => {
|
|
let id = 0
|
|
const ctx = harness({
|
|
create: async () => ({
|
|
data: { location: { directory: config.cwd }, data: { ...info(), id: `pty-${++id}` } },
|
|
}),
|
|
get: async () => ({
|
|
data: { location: { directory: config.cwd }, data: { ...info(), id: `pty-${id}` } },
|
|
}),
|
|
})
|
|
|
|
await ctx.manager.start("setup", { ...config, projectId: "prj-a" }, () => undefined)
|
|
await ctx.manager.start("setup", { ...config, projectId: "prj-b" }, () => undefined)
|
|
|
|
expect(ctx.snapshots.at(-1)).toEqual([
|
|
expect.objectContaining({ projectId: "prj-a", worktreeId: "wt-1", kind: "setup" }),
|
|
expect.objectContaining({ projectId: "prj-b", worktreeId: "wt-1", kind: "setup" }),
|
|
])
|
|
expect(ctx.manager.active("setup", "wt-1", "prj-a")).toBe(true)
|
|
expect(ctx.manager.active("setup", "wt-1", "prj-b")).toBe(true)
|
|
|
|
await ctx.manager.clear("setup", "wt-1", "prj-a")
|
|
expect(ctx.manager.active("setup", "wt-1", "prj-a")).toBe(false)
|
|
expect(ctx.manager.active("setup", "wt-1", "prj-b")).toBe(true)
|
|
})
|
|
|
|
it("builds canonical authenticated replay URLs", () => {
|
|
const value = buildScriptTerminalWsUrl(
|
|
{ baseUrl: "http://127.0.0.1:4096", password: "secret" },
|
|
"pty / 1",
|
|
"/repo/worktree",
|
|
)
|
|
const url = new URL(value)
|
|
|
|
expect(url.protocol).toBe("ws:")
|
|
expect(url.pathname).toBe("/api/pty/pty%20%2F%201/connect")
|
|
expect(url.searchParams.get("location[directory]")).toBe("/repo/worktree")
|
|
expect(url.searchParams.get("cursor")).toBe("0")
|
|
expect(url.searchParams.get("replayExited")).toBe("1")
|
|
expect(url.searchParams.get("auth_token")).toBe(Buffer.from("kilo:secret").toString("base64"))
|
|
})
|
|
|
|
it("finishes once on a natural exit and retains the replayable terminal", async () => {
|
|
const ctx = harness()
|
|
const done: unknown[] = []
|
|
|
|
await ctx.manager.start("run", config, (exit) => done.push(exit))
|
|
const terminalId = ctx.snapshots.at(-1)?.[0]?.terminalId
|
|
if (!terminalId) throw new Error("missing Run terminal")
|
|
ctx.manager.exited("pty-1", 17)
|
|
ctx.manager.exited("pty-1", 17)
|
|
|
|
expect(done).toEqual([{ exitCode: 17 }])
|
|
expect(ctx.calls.remove).toEqual([])
|
|
expect(ctx.snapshots.at(-1)).toEqual([expect.objectContaining({ terminalId, state: "exited", exitCode: 17 })])
|
|
|
|
expect(ctx.manager.intercept({ type: "agentManager.terminal.close", terminalId })).toBe(true)
|
|
await wait()
|
|
expect(ctx.calls.remove).toEqual([{ ptyID: "pty-1", location: { directory: "/repo/worktree" } }])
|
|
expect(ctx.closed).toEqual([terminalId])
|
|
expect(ctx.snapshots.at(-1)).toEqual([])
|
|
})
|
|
|
|
it("reconciles a PTY that exited before registration", async () => {
|
|
const ctx = harness({
|
|
get: async () => ({ data: { location: { directory: config.cwd }, data: info("exited", 7) } }),
|
|
})
|
|
const done: unknown[] = []
|
|
|
|
await ctx.manager.start("run", config, (exit) => done.push(exit))
|
|
|
|
expect(done).toEqual([{ exitCode: 7 }])
|
|
expect(ctx.snapshots.at(-1)).toEqual([expect.objectContaining({ state: "exited", exitCode: 7 })])
|
|
})
|
|
|
|
it("reconciles an exit event that arrives before create registration", async () => {
|
|
const gate = deferred<PtyResponse>()
|
|
let state = info()
|
|
const ctx = harness({
|
|
create: async () => gate.promise,
|
|
get: async () => ({ data: { location: { directory: config.cwd }, data: state } }),
|
|
})
|
|
const done: unknown[] = []
|
|
const started = ctx.manager.start("run", config, (exit) => done.push(exit))
|
|
|
|
await wait()
|
|
state = info("exited", 9)
|
|
ctx.manager.exited("pty-1", 9)
|
|
gate.resolve({ data: { location: { directory: config.cwd }, data: info() } })
|
|
await started
|
|
|
|
expect(done).toEqual([{ exitCode: 9 }])
|
|
expect(ctx.snapshots.at(-1)).toEqual([expect.objectContaining({ state: "exited", exitCode: 9 })])
|
|
})
|
|
|
|
it("treats an already removed backend PTY as a successful close", async () => {
|
|
const ctx = harness({ remove: async () => ({ error: { _tag: "PtyNotFoundError", status: 404 } }) })
|
|
const done: unknown[] = []
|
|
|
|
await ctx.manager.start("run", config, (exit) => done.push(exit))
|
|
const terminalId = ctx.snapshots.at(-1)?.[0]?.terminalId
|
|
if (!terminalId) throw new Error("missing Run terminal")
|
|
|
|
expect(await ctx.manager.close(terminalId)).toBe(true)
|
|
expect(done).toEqual([{ stopped: true }])
|
|
expect(ctx.snapshots.at(-1)).toEqual([])
|
|
})
|
|
|
|
it("stops a PTY when stop races startup", async () => {
|
|
const gate = deferred<PtyResponse>()
|
|
const ctx = harness({ create: async () => gate.promise })
|
|
const statuses: RunStatus[] = []
|
|
const run = new RunScriptManager(
|
|
() => undefined,
|
|
(status) => statuses.push({ ...status }),
|
|
() => new Date("2026-01-02T03:04:05.000Z"),
|
|
)
|
|
const started = run.start("wt-1", () => ctx.manager.start("run", config, (exit) => run.finish("wt-1", exit)))
|
|
|
|
await wait()
|
|
await run.stop("wt-1")
|
|
gate.resolve({ data: { location: { directory: config.cwd }, data: info() } })
|
|
await started
|
|
await wait()
|
|
|
|
expect(ctx.calls.remove).toEqual([{ ptyID: "pty-1", location: { directory: "/repo/worktree" } }])
|
|
expect(statuses.map((status) => status.state)).toEqual(["running", "stopping", "idle"])
|
|
expect(run.status("wt-1")).toMatchObject({ state: "idle", stopped: true })
|
|
})
|
|
|
|
it("intercepts resize and stops a running terminal when it closes", async () => {
|
|
const ctx = harness()
|
|
const done: unknown[] = []
|
|
|
|
await ctx.manager.start("run", config, (exit) => done.push(exit))
|
|
const terminalId = ctx.snapshots.at(-1)?.[0]?.terminalId
|
|
if (!terminalId) throw new Error("missing Run terminal")
|
|
expect(ctx.manager.intercept({ type: "agentManager.terminal.resize", terminalId, cols: 120, rows: 40 })).toBe(true)
|
|
await wait()
|
|
expect(ctx.calls.update).toEqual([
|
|
{ ptyID: "pty-1", location: { directory: "/repo/worktree" }, size: { cols: 120, rows: 40 } },
|
|
])
|
|
|
|
expect(ctx.manager.intercept({ type: "agentManager.terminal.close", terminalId })).toBe(true)
|
|
await wait()
|
|
expect(ctx.calls.remove).toEqual([{ ptyID: "pty-1", location: { directory: "/repo/worktree" } }])
|
|
expect(done).toEqual([{ stopped: true }])
|
|
expect(ctx.closed).toEqual([terminalId])
|
|
expect(ctx.snapshots.at(-1)).toEqual([])
|
|
})
|
|
|
|
it("retries closure after a Run terminal removal fails", async () => {
|
|
let attempt = 0
|
|
const ctx = harness({
|
|
remove: async () => {
|
|
attempt++
|
|
if (attempt === 1) return { error: new Error("still running") }
|
|
return { data: undefined }
|
|
},
|
|
})
|
|
|
|
await ctx.manager.start("run", config, () => undefined)
|
|
const terminalId = ctx.snapshots.at(-1)?.[0]?.terminalId
|
|
if (!terminalId) throw new Error("missing Run terminal")
|
|
expect(ctx.manager.intercept({ type: "agentManager.terminal.close", terminalId })).toBe(true)
|
|
await wait()
|
|
|
|
expect(ctx.closed).toEqual([])
|
|
expect(ctx.snapshots.at(-1)).toEqual([expect.objectContaining({ terminalId, state: "failed" })])
|
|
|
|
expect(ctx.manager.intercept({ type: "agentManager.terminal.close", terminalId })).toBe(true)
|
|
await wait()
|
|
|
|
expect(ctx.calls.remove).toHaveLength(2)
|
|
expect(ctx.closed).toEqual([terminalId])
|
|
expect(ctx.snapshots.at(-1)).toEqual([])
|
|
})
|
|
|
|
it("drops a retained Run terminal when the backend evicts it", async () => {
|
|
const ctx = harness()
|
|
const done: unknown[] = []
|
|
|
|
await ctx.manager.start("run", config, (exit) => done.push(exit))
|
|
ctx.manager.exited("pty-1", 0)
|
|
ctx.manager.deleted("pty-1")
|
|
|
|
expect(done).toEqual([{ exitCode: 0 }])
|
|
expect(ctx.snapshots.at(-1)).toEqual([])
|
|
expect(ctx.calls.remove).toEqual([])
|
|
})
|
|
|
|
it("reconciles a natural exit missed during an event-stream reconnect", async () => {
|
|
let state: PtyInfo = info()
|
|
const ctx = harness({ get: async () => ({ data: { location: { directory: config.cwd }, data: state } }) })
|
|
const done: unknown[] = []
|
|
|
|
await ctx.manager.start("run", config, (exit) => done.push(exit))
|
|
state = info("exited", 23)
|
|
await ctx.manager.sync()
|
|
|
|
expect(done).toEqual([{ exitCode: 23 }])
|
|
expect(ctx.snapshots.at(-1)).toEqual([expect.objectContaining({ state: "exited", exitCode: 23 })])
|
|
})
|
|
|
|
it("clears retained exited terminals by worktree context", async () => {
|
|
const ctx = harness()
|
|
|
|
await ctx.manager.start("run", config, () => undefined)
|
|
ctx.manager.exited("pty-1", 0)
|
|
|
|
expect(await ctx.manager.clear("run", "wt-1")).toBe(true)
|
|
expect(ctx.calls.remove).toEqual([{ ptyID: "pty-1", location: { directory: "/repo/worktree" } }])
|
|
expect(ctx.snapshots.at(-1)).toEqual([])
|
|
})
|
|
|
|
it("replays the full retained snapshot after a webview reload", async () => {
|
|
const ctx = harness()
|
|
|
|
await ctx.manager.start("run", config, () => undefined)
|
|
const first = ctx.snapshots.at(-1)
|
|
ctx.manager.snapshot()
|
|
|
|
expect(ctx.snapshots.at(-1)).toEqual(first)
|
|
})
|
|
})
|
|
|
|
describe("ScriptTerminalManager Setup kind", () => {
|
|
it("creates a Setup PTY labeled Setup and tracks its kind", async () => {
|
|
const ctx = harness()
|
|
|
|
await ctx.manager.start("setup", config, () => undefined)
|
|
|
|
expect(ctx.calls.create[0]?.title).toBe("Setup")
|
|
expect(ctx.snapshots.at(-1)).toEqual([expect.objectContaining({ kind: "setup", title: "Setup", state: "running" })])
|
|
expect(ctx.manager.active("setup", "wt-1")).toBe(true)
|
|
expect(ctx.manager.active("run", "wt-1")).toBe(false)
|
|
})
|
|
|
|
it("ignores a user close while Setup is running", async () => {
|
|
const ctx = harness()
|
|
const done: unknown[] = []
|
|
|
|
await ctx.manager.start("setup", config, (exit) => done.push(exit))
|
|
const terminalId = ctx.snapshots.at(-1)?.[0]?.terminalId
|
|
if (!terminalId) throw new Error("missing Setup terminal")
|
|
|
|
expect(ctx.manager.intercept({ type: "agentManager.terminal.close", terminalId })).toBe(true)
|
|
await wait()
|
|
|
|
expect(ctx.calls.remove).toEqual([])
|
|
expect(ctx.closed).toEqual([])
|
|
expect(done).toEqual([])
|
|
expect(ctx.snapshots.at(-1)).toEqual([expect.objectContaining({ terminalId, state: "running" })])
|
|
expect(ctx.logs.some((msg) => msg.includes("Ignored close"))).toBe(true)
|
|
})
|
|
|
|
it("force-stops a running Setup when the worktree is cleared", async () => {
|
|
const ctx = harness()
|
|
const done: unknown[] = []
|
|
|
|
await ctx.manager.start("setup", config, (exit) => done.push(exit))
|
|
|
|
expect(await ctx.manager.clear("setup", "wt-1")).toBe(true)
|
|
expect(ctx.calls.remove).toEqual([{ ptyID: "pty-1", location: { directory: "/repo/worktree" } }])
|
|
expect(done).toEqual([{ stopped: true }])
|
|
expect(ctx.snapshots.at(-1)).toEqual([])
|
|
expect(ctx.manager.active("setup", "wt-1")).toBe(false)
|
|
})
|
|
|
|
it("closes an exited Setup terminal and reports inactive", async () => {
|
|
const ctx = harness()
|
|
const done: unknown[] = []
|
|
|
|
await ctx.manager.start("setup", config, (exit) => done.push(exit))
|
|
const terminalId = ctx.snapshots.at(-1)?.[0]?.terminalId
|
|
if (!terminalId) throw new Error("missing Setup terminal")
|
|
ctx.manager.exited("pty-1", 0)
|
|
|
|
expect(ctx.manager.active("setup", "wt-1")).toBe(false)
|
|
expect(await ctx.manager.close(terminalId)).toBe(true)
|
|
expect(done).toEqual([{ exitCode: 0 }])
|
|
expect(ctx.snapshots.at(-1)).toEqual([])
|
|
})
|
|
|
|
it("retains the tab as failed when killed, and survives the backend deleted event", async () => {
|
|
let removes = 0
|
|
const ctx = harness({
|
|
remove: async () => {
|
|
removes += 1
|
|
// The first remove (the kill itself) succeeds; the PTY is gone for
|
|
// any later remove, which must still close the retained tab.
|
|
return removes > 1 ? { error: { _tag: "PtyNotFoundError" } } : { data: undefined }
|
|
},
|
|
})
|
|
const done: unknown[] = []
|
|
|
|
const handle = await ctx.manager.start("setup", config, (exit) => done.push(exit))
|
|
const terminalId = ctx.snapshots.at(-1)?.[0]?.terminalId
|
|
if (!terminalId) throw new Error("missing Setup terminal")
|
|
|
|
handle.kill?.("Setup script timed out after 5 minutes")
|
|
await wait()
|
|
|
|
// The process tree is killed but the tab keeps its partial output.
|
|
expect(ctx.calls.remove).toEqual([{ ptyID: "pty-1", location: { directory: "/repo/worktree" } }])
|
|
expect(done).toEqual([{ error: "Setup script timed out after 5 minutes" }])
|
|
expect(ctx.snapshots.at(-1)).toEqual([expect.objectContaining({ terminalId, state: "failed" })])
|
|
|
|
// The backend confirming the deletion must not drop the retained tab.
|
|
ctx.manager.deleted("pty-1")
|
|
expect(ctx.snapshots.at(-1)).toEqual([expect.objectContaining({ terminalId, state: "failed" })])
|
|
|
|
// Closing the retained tab still works when the PTY is already gone.
|
|
expect(await ctx.manager.close(terminalId)).toBe(true)
|
|
expect(ctx.snapshots.at(-1)).toEqual([])
|
|
})
|
|
|
|
it("stops a running Setup when the user deliberately stops it", async () => {
|
|
const ctx = harness()
|
|
const done: unknown[] = []
|
|
|
|
await ctx.manager.start("setup", config, (exit) => done.push(exit))
|
|
const terminalId = ctx.snapshots.at(-1)?.[0]?.terminalId
|
|
if (!terminalId) throw new Error("missing Setup terminal")
|
|
|
|
expect(ctx.manager.intercept({ type: "agentManager.terminal.stop", terminalId })).toBe(true)
|
|
await wait()
|
|
|
|
expect(ctx.calls.remove).toEqual([{ ptyID: "pty-1", location: { directory: "/repo/worktree" } }])
|
|
expect(done).toEqual([{ stopped: true }])
|
|
expect(ctx.closed).toEqual([terminalId])
|
|
expect(ctx.snapshots.at(-1)).toEqual([])
|
|
})
|
|
})
|