mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-08 09:37:47 +03:00
861 lines
29 KiB
TypeScript
861 lines
29 KiB
TypeScript
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
|
import {
|
|
ClientError,
|
|
OpenCode,
|
|
type EventSubscribeOutput,
|
|
type ExperimentalJobListOutput,
|
|
type SessionActiveOutput,
|
|
type SessionInboxListOutput,
|
|
type SessionMessageAssistantTool,
|
|
type SessionMessageInfo,
|
|
} from "@opencode/client/promise"
|
|
import { runNonInteractivePrompt } from "../../src/run/noninteractive"
|
|
|
|
type V2Event = EventSubscribeOutput
|
|
type FormInfo = Extract<V2Event, { type: "form.created" }>["data"]["form"]
|
|
const location = { directory: "/work tree", workspaceID: "wrk_1" }
|
|
|
|
function ok<T>(data: T) {
|
|
return Promise.resolve(data)
|
|
}
|
|
|
|
function form(id: string, sessionID: string): FormInfo {
|
|
return {
|
|
id,
|
|
sessionID,
|
|
title: "Input requested",
|
|
fields: [{ key: "authorization", type: "external", url: "https://example.com/form" }],
|
|
}
|
|
}
|
|
|
|
function formCreated(info: FormInfo, eventLocation = location): V2Event {
|
|
return { id: `evt_${info.id}`, created: 0, type: "form.created", location: eventLocation, data: { form: info } }
|
|
}
|
|
|
|
function prompted(inboxID: string): V2Event {
|
|
return {
|
|
id: "evt_prompted",
|
|
created: 0,
|
|
type: "session.inbox.delivered",
|
|
durable: { aggregateID: "ses_1", seq: 0, version: 1 },
|
|
data: { sessionID: "ses_1", inboxID },
|
|
}
|
|
}
|
|
|
|
function settled(outcome: "success" | "interrupted" = "success"): V2Event {
|
|
if (outcome === "interrupted")
|
|
return {
|
|
id: "evt_interrupted",
|
|
created: 0,
|
|
type: "session.execution.interrupted",
|
|
durable: { aggregateID: "ses_1", seq: 1, version: 1 },
|
|
data: { sessionID: "ses_1", reason: "user" },
|
|
}
|
|
return {
|
|
id: "evt_succeeded",
|
|
created: 0,
|
|
type: "session.execution.succeeded",
|
|
durable: { aggregateID: "ses_1", seq: 1, version: 1 },
|
|
data: { sessionID: "ses_1" },
|
|
}
|
|
}
|
|
|
|
function stepStarted(): V2Event {
|
|
return {
|
|
id: "evt_step_started",
|
|
created: 1,
|
|
type: "session.step.started",
|
|
durable: { aggregateID: "ses_1", seq: 1, version: 1 },
|
|
data: {
|
|
started: 1,
|
|
sessionID: "ses_1",
|
|
assistantMessageID: "msg_assistant",
|
|
agent: "build",
|
|
model: { providerID: "test", id: "test-model" },
|
|
},
|
|
}
|
|
}
|
|
|
|
function stepFailed(message: string): V2Event {
|
|
return {
|
|
id: "evt_step_failed",
|
|
created: 2,
|
|
type: "session.step.failed",
|
|
durable: { aggregateID: "ses_1", seq: 2, version: 1 },
|
|
data: {
|
|
sessionID: "ses_1",
|
|
assistantMessageID: "msg_assistant",
|
|
error: { type: "provider.transport", message },
|
|
},
|
|
}
|
|
}
|
|
|
|
function executionFailed(message: string): V2Event {
|
|
return {
|
|
id: "evt_execution_failed",
|
|
created: 3,
|
|
type: "session.execution.failed",
|
|
durable: { aggregateID: "ses_1", seq: 3, version: 1 },
|
|
data: {
|
|
sessionID: "ses_1",
|
|
error: { type: "provider.transport", message },
|
|
},
|
|
}
|
|
}
|
|
|
|
function idleMessage(outcome: "succeeded" | "failed" | "interrupted", id = "msg_idle") {
|
|
return {
|
|
id,
|
|
type: "idle",
|
|
outcome,
|
|
...(outcome === "failed" ? { error: { type: "provider.transport", message: "Execution failed" } } : {}),
|
|
time: { created: 5 },
|
|
} satisfies SessionMessageInfo
|
|
}
|
|
|
|
// A step that failed and was retried, followed by the step that recovered.
|
|
function recoveredMessages(outcome: "succeeded" | "failed" | "interrupted") {
|
|
return (messageID: string): SessionMessageInfo[] => [
|
|
idleMessage(outcome),
|
|
{
|
|
id: "msg_recovered",
|
|
type: "assistant",
|
|
agent: "build",
|
|
model: { providerID: "test", id: "test-model" },
|
|
content: [{ type: "text", text: "Recovered answer" }],
|
|
finish: "stop",
|
|
time: { created: 3, completed: 4 },
|
|
},
|
|
{
|
|
id: "msg_assistant",
|
|
type: "assistant",
|
|
agent: "build",
|
|
model: { providerID: "test", id: "test-model" },
|
|
content: [],
|
|
finish: "error",
|
|
error: { type: "provider.transport", message: "Recovered transport error" },
|
|
time: { created: 2, completed: 3 },
|
|
},
|
|
{ id: messageID, type: "user", text: "hello", time: { created: 1 } },
|
|
]
|
|
}
|
|
|
|
function jsonLines(stdout: string) {
|
|
return stdout
|
|
.split("\n")
|
|
.filter(Boolean)
|
|
.map((line) => JSON.parse(line))
|
|
}
|
|
|
|
function failedTool(inboxID: string): V2Event[] {
|
|
return [
|
|
prompted(inboxID),
|
|
{
|
|
id: "evt_failed_tool_input",
|
|
created: 1,
|
|
type: "session.tool.input.started",
|
|
durable: { aggregateID: "ses_1", seq: 1, version: 1 },
|
|
data: {
|
|
sessionID: "ses_1",
|
|
assistantMessageID: "msg_failed_tool",
|
|
id: "call_failed_tool",
|
|
name: "shell",
|
|
},
|
|
},
|
|
{
|
|
id: "evt_failed_tool_called",
|
|
created: 2,
|
|
type: "session.tool.called",
|
|
durable: { aggregateID: "ses_1", seq: 2, version: 1 },
|
|
data: {
|
|
sessionID: "ses_1",
|
|
assistantMessageID: "msg_failed_tool",
|
|
id: "call_failed_tool",
|
|
input: { command: "printf partial && false" },
|
|
executed: true,
|
|
},
|
|
},
|
|
{
|
|
id: "evt_failed_tool_progress",
|
|
created: 3,
|
|
type: "session.tool.progress",
|
|
data: {
|
|
sessionID: "ses_1",
|
|
assistantMessageID: "msg_failed_tool",
|
|
id: "call_failed_tool",
|
|
metadata: { checkpoint: 1 },
|
|
},
|
|
},
|
|
{
|
|
id: "evt_failed_tool_terminal",
|
|
created: 4,
|
|
type: "session.tool.failed",
|
|
durable: { aggregateID: "ses_1", seq: 4, version: 2 },
|
|
data: {
|
|
sessionID: "ses_1",
|
|
assistantMessageID: "msg_failed_tool",
|
|
id: "call_failed_tool",
|
|
error: { type: "unknown", message: "tool failed" },
|
|
metadata: { checkpoint: 1 },
|
|
content: [{ type: "text", text: "partial output" }],
|
|
executed: true,
|
|
},
|
|
},
|
|
settled(),
|
|
]
|
|
}
|
|
|
|
function successfulGrep(inboxID: string): V2Event[] {
|
|
const text = "Found 2 matches\n/src/a.ts:\n Line 1: needle\n/src/b.ts:\n Line 2: needle"
|
|
return [
|
|
prompted(inboxID),
|
|
{
|
|
id: "evt_grep_input",
|
|
created: 1,
|
|
type: "session.tool.input.started",
|
|
durable: { aggregateID: "ses_1", seq: 1, version: 1 },
|
|
data: {
|
|
sessionID: "ses_1",
|
|
assistantMessageID: "msg_grep",
|
|
id: "call_grep",
|
|
name: "grep",
|
|
},
|
|
},
|
|
{
|
|
id: "evt_grep_called",
|
|
created: 2,
|
|
type: "session.tool.called",
|
|
durable: { aggregateID: "ses_1", seq: 2, version: 1 },
|
|
data: {
|
|
sessionID: "ses_1",
|
|
assistantMessageID: "msg_grep",
|
|
id: "call_grep",
|
|
input: { pattern: "needle" },
|
|
executed: true,
|
|
},
|
|
},
|
|
{
|
|
id: "evt_grep_success",
|
|
created: 3,
|
|
type: "session.tool.success",
|
|
durable: { aggregateID: "ses_1", seq: 3, version: 2 },
|
|
data: {
|
|
sessionID: "ses_1",
|
|
assistantMessageID: "msg_grep",
|
|
id: "call_grep",
|
|
metadata: { matches: 2 },
|
|
content: [{ type: "text", text }],
|
|
executed: false,
|
|
},
|
|
},
|
|
settled(),
|
|
]
|
|
}
|
|
|
|
// Runs one non-interactive prompt against a mocked SDK. `turn` produces the
|
|
// live events the prompt admission triggers, keyed by the generated message ID.
|
|
async function run(input: {
|
|
turn: (inboxID: string) => V2Event[]
|
|
pendingForms?: FormInfo[]
|
|
standalone?: boolean
|
|
format?: "default" | "json"
|
|
cancel?: (input: { sessionID: string; formID: string }) => Promise<void>
|
|
renderTool?: (part: SessionMessageAssistantTool) => Promise<void>
|
|
renderToolError?: (part: SessionMessageAssistantTool) => Promise<void>
|
|
messages?: (inboxID: string) => SessionMessageInfo[]
|
|
wait?: () => Promise<void>
|
|
jobs?: () => ExperimentalJobListOutput
|
|
inbox?: (sessionID: string) => SessionInboxListOutput
|
|
active?: () => SessionActiveOutput
|
|
terminalDelay?: number
|
|
signal?: AbortSignal
|
|
}) {
|
|
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
|
|
const values: V2Event[] = [{ id: "evt_connected", type: "server.connected", data: {} }]
|
|
let wake: (() => void) | undefined
|
|
const wait = Promise.withResolvers<void>()
|
|
const stream = (async function* (): AsyncGenerator<V2Event, void, unknown> {
|
|
while (true) {
|
|
const value = values.shift()
|
|
if (!value) {
|
|
await new Promise<void>((resolve) => {
|
|
wake = resolve
|
|
})
|
|
continue
|
|
}
|
|
if (value.type.startsWith("session.execution.")) {
|
|
if (input.terminalDelay) await Bun.sleep(input.terminalDelay)
|
|
setTimeout(wait.resolve, 0)
|
|
}
|
|
yield value
|
|
}
|
|
})()
|
|
spyOn(sdk.event, "subscribe").mockImplementation((() => stream) as typeof sdk.event.subscribe)
|
|
spyOn(sdk.permission, "list").mockImplementation(() => ok([]) as never)
|
|
spyOn(sdk.session.form, "list").mockImplementation(
|
|
(request) => ok(input.pendingForms?.filter((item) => item.sessionID === request.sessionID) ?? []) as never,
|
|
)
|
|
spyOn(sdk.form, "list").mockImplementation(
|
|
() =>
|
|
ok({
|
|
location: { ...location, project: { id: "proj_1", directory: location.directory } },
|
|
data: input.pendingForms?.filter((item) => item.sessionID === "global") ?? [],
|
|
}) as never,
|
|
)
|
|
spyOn(sdk.session.form, "cancel").mockImplementation((request) => (input.cancel?.(request) ?? ok(undefined)) as never)
|
|
let promptID = "msg_prompt"
|
|
spyOn(sdk.experimental.session, "wait").mockImplementation(() => input.wait?.() ?? wait.promise)
|
|
spyOn(sdk.experimental.job, "list").mockImplementation((async () => input.jobs?.() ?? []) as never)
|
|
spyOn(sdk.session, "active").mockImplementation((async () => input.active?.() ?? {}) as never)
|
|
spyOn(sdk.session.inbox, "list").mockImplementation(
|
|
(async (request: { sessionID: string }) => input.inbox?.(request.sessionID) ?? []) as never,
|
|
)
|
|
spyOn(sdk.session.inbox, "cancel").mockImplementation(() => ok(undefined))
|
|
spyOn(sdk.session, "interrupt").mockImplementation(() => ok({ interrupted: true }) as never)
|
|
spyOn(sdk.shell, "remove").mockImplementation(() => ok(undefined) as never)
|
|
spyOn(sdk.session, "get").mockImplementation(
|
|
({ sessionID }) => ok({ id: sessionID, parentID: sessionID === "ses_child" ? "ses_1" : undefined }) as never,
|
|
)
|
|
spyOn(sdk.message, "list").mockImplementation(() =>
|
|
ok({
|
|
data: input.messages?.(promptID) ?? [{ id: promptID, type: "user", text: "hello", time: { created: 1 } }],
|
|
cursor: {},
|
|
}),
|
|
)
|
|
spyOn(sdk.session, "prompt").mockImplementation((request) => {
|
|
const messageID = request.id ?? "msg_prompt"
|
|
promptID = messageID
|
|
values.push(...input.turn(messageID))
|
|
wake?.()
|
|
wake = undefined
|
|
return ok({ id: messageID, sessionID: "ses_1", time: { created: 1 } }) as never
|
|
})
|
|
await runNonInteractivePrompt({
|
|
client: sdk,
|
|
sessionID: "ses_1",
|
|
location,
|
|
message: "hello",
|
|
files: [],
|
|
thinking: false,
|
|
format: input.format ?? "default",
|
|
auto: false,
|
|
standalone: input.standalone ?? false,
|
|
signal: input.signal ?? new AbortController().signal,
|
|
renderTool: input.renderTool ?? (() => Promise.resolve()),
|
|
renderToolError: input.renderToolError ?? (() => Promise.resolve()),
|
|
})
|
|
return sdk
|
|
}
|
|
|
|
async function capture(input: Parameters<typeof run>[0]) {
|
|
const stdout: string[] = []
|
|
const stderr: string[] = []
|
|
const exitCode = process.exitCode
|
|
const stdoutWrite = spyOn(process.stdout, "write").mockImplementation((chunk) => {
|
|
stdout.push(String(chunk))
|
|
return true
|
|
})
|
|
const stderrWrite = spyOn(process.stderr, "write").mockImplementation((chunk) => {
|
|
stderr.push(String(chunk))
|
|
return true
|
|
})
|
|
try {
|
|
await run(input)
|
|
return { stdout: stdout.join(""), stderr: stderr.join(""), exitCode: process.exitCode }
|
|
} finally {
|
|
process.exitCode = exitCode ?? 0
|
|
stdoutWrite.mockRestore()
|
|
stderrWrite.mockRestore()
|
|
}
|
|
}
|
|
|
|
afterEach(() => {
|
|
mock.restore()
|
|
process.exitCode = 0
|
|
})
|
|
|
|
function shellJob(id: string, command: string): ExperimentalJobListOutput[number] {
|
|
return { kind: "shell", id, sessionID: "ses_1", status: "running", shellID: id, command, time: { started: 1 } }
|
|
}
|
|
|
|
const subagentJob: ExperimentalJobListOutput[number] = {
|
|
kind: "subagent",
|
|
id: "ses_child",
|
|
sessionID: "ses_1",
|
|
status: "running",
|
|
childSessionID: "ses_child",
|
|
agent: "explore",
|
|
description: "scan repo",
|
|
time: { started: 1 },
|
|
}
|
|
|
|
describe("runNonInteractivePrompt", () => {
|
|
test("keeps formatted tool output and compact tool metadata in JSON", async () => {
|
|
const output = await capture({ format: "json", turn: successfulGrep })
|
|
const events = output.stdout
|
|
.split("\n")
|
|
.filter(Boolean)
|
|
.map((line) => JSON.parse(line))
|
|
|
|
expect(events).toHaveLength(1)
|
|
expect(events[0]).toMatchObject({
|
|
type: "tool_use",
|
|
part: {
|
|
tool: "grep",
|
|
state: {
|
|
status: "completed",
|
|
output: expect.stringContaining("Found 2 matches"),
|
|
metadata: {
|
|
metadata: { matches: 2 },
|
|
content: [{ type: "text", text: expect.stringContaining("/src/a.ts") }],
|
|
},
|
|
},
|
|
},
|
|
})
|
|
expect(events[0].part.state.metadata.metadata).toEqual({ matches: 2 })
|
|
expect(events[0].part.state.metadata.result).toBeUndefined()
|
|
})
|
|
|
|
test("uses session.wait then reconciles projected output without a terminal event", async () => {
|
|
const idle = Promise.withResolvers<void>()
|
|
let done = false
|
|
const task = capture({
|
|
format: "json",
|
|
turn: (messageID) => [prompted(messageID)],
|
|
wait: () => idle.promise,
|
|
messages: (messageID) => [
|
|
idleMessage("succeeded"),
|
|
{
|
|
id: "msg_assistant",
|
|
type: "assistant",
|
|
agent: "build",
|
|
model: { providerID: "test", id: "test-model" },
|
|
content: [{ type: "text", text: "projected answer" }],
|
|
finish: "stop",
|
|
time: { created: 2, completed: 3 },
|
|
},
|
|
{ id: messageID, type: "user", text: "hello", time: { created: 1 } },
|
|
],
|
|
}).then((output) => {
|
|
done = true
|
|
return output
|
|
})
|
|
|
|
await Bun.sleep(0)
|
|
await Bun.sleep(0)
|
|
expect(done).toBe(false)
|
|
idle.resolve()
|
|
const output = await task
|
|
expect(
|
|
output.stdout
|
|
.split("\n")
|
|
.filter(Boolean)
|
|
.map((line) => JSON.parse(line)),
|
|
).toEqual([
|
|
expect.objectContaining({ type: "step_start", part: expect.objectContaining({ messageID: "msg_assistant" }) }),
|
|
expect.objectContaining({ type: "text", part: expect.objectContaining({ text: "projected answer" }) }),
|
|
])
|
|
})
|
|
|
|
test("reports usage for a step that ends after the Session goes idle", async () => {
|
|
const tokens = { input: 3, output: 5, reasoning: 7, cache: { read: 11, write: 13 } }
|
|
const assistant = (messageID: string): SessionMessageInfo[] => [
|
|
idleMessage("succeeded"),
|
|
{
|
|
id: "msg_assistant",
|
|
type: "assistant",
|
|
agent: "build",
|
|
model: { providerID: "test", id: "test-model" },
|
|
content: [{ type: "text", text: "done" }],
|
|
finish: "stop",
|
|
cost: 0.25,
|
|
tokens,
|
|
time: { created: 2, completed: 3 },
|
|
},
|
|
{ id: messageID, type: "user", text: "hello", time: { created: 1 } },
|
|
]
|
|
const lines = async (turn: (messageID: string) => V2Event[]) =>
|
|
(await capture({ format: "json", turn, messages: assistant, wait: () => Promise.resolve() })).stdout
|
|
.split("\n")
|
|
.filter(Boolean)
|
|
.map((line) => JSON.parse(line))
|
|
|
|
const missed = await lines((messageID) => [prompted(messageID), stepStarted()])
|
|
expect(missed.map((event) => event.type)).toEqual(["step_start", "text", "step_finish"])
|
|
expect(missed[2].part).toMatchObject({ messageID: "msg_assistant", reason: "stop", cost: 0.25, tokens })
|
|
|
|
const seen = await lines((messageID) => [
|
|
prompted(messageID),
|
|
stepStarted(),
|
|
{
|
|
id: "evt_step_ended",
|
|
created: 2,
|
|
type: "session.step.ended",
|
|
durable: { aggregateID: "ses_1", seq: 2, version: 1 },
|
|
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", finish: "stop", cost: 0.25, tokens },
|
|
},
|
|
settled(),
|
|
])
|
|
expect(seen.filter((event) => event.type === "step_finish")).toHaveLength(1)
|
|
})
|
|
|
|
test("reports an observed execution failure before prompt promotion", async () => {
|
|
const output = await capture({
|
|
format: "json",
|
|
turn: () => [executionFailed("instructions unavailable")],
|
|
messages: () => [],
|
|
})
|
|
|
|
expect(
|
|
output.stdout
|
|
.split("\n")
|
|
.filter(Boolean)
|
|
.map((line) => JSON.parse(line)),
|
|
).toEqual([
|
|
expect.objectContaining({
|
|
type: "error",
|
|
error: { type: "provider.transport", message: "instructions unavailable" },
|
|
}),
|
|
])
|
|
expect(output.exitCode).toBe(1)
|
|
})
|
|
|
|
test("waits for a terminal failure when idle wins before projection", async () => {
|
|
for (const promotedBeforeFailure of [true, false]) {
|
|
const output = await capture({
|
|
format: "json",
|
|
turn: (messageID) => [
|
|
...(promotedBeforeFailure ? [prompted(messageID)] : []),
|
|
executionFailed("selection unavailable"),
|
|
],
|
|
messages: (messageID) =>
|
|
promotedBeforeFailure ? [{ id: messageID, type: "user", text: "hello", time: { created: 1 } }] : [],
|
|
wait: () => Promise.resolve(),
|
|
terminalDelay: 10,
|
|
})
|
|
|
|
expect(output.exitCode).toBe(1)
|
|
expect(output.stdout).toContain("selection unavailable")
|
|
}
|
|
})
|
|
|
|
for (const standalone of [true, false]) {
|
|
test(`${standalone ? "a standalone" : "an attached"} run ${standalone ? "cancels" : "leaves"} MCP input requests in its location`, async () => {
|
|
const cancelled: { sessionID: string; formID: string }[] = []
|
|
const output = await capture({
|
|
format: "json",
|
|
standalone,
|
|
pendingForms: [form("frm_pending", "ses_1"), form("frm_pending_global", "global")],
|
|
turn: (messageID) => [
|
|
formCreated(form("frm_live", "global")),
|
|
formCreated(form("frm_elsewhere", "global"), { ...location, directory: "/elsewhere" }),
|
|
prompted(messageID),
|
|
settled(),
|
|
],
|
|
cancel: (request) => {
|
|
cancelled.push(request)
|
|
return Promise.resolve()
|
|
},
|
|
})
|
|
const global = standalone ? ["frm_live", "frm_pending_global"] : []
|
|
expect(cancelled.map((request) => request.formID).sort()).toEqual(["frm_pending", ...global].sort())
|
|
expect(output.exitCode).toBe(1)
|
|
expect(jsonLines(output.stdout).filter((event) => event.type === "error")).toHaveLength(1)
|
|
})
|
|
}
|
|
|
|
test("renders a native terminal failure snapshot when live progress was missed", async () => {
|
|
const rendered: SessionMessageAssistantTool[] = []
|
|
const failed: SessionMessageAssistantTool[] = []
|
|
await capture({
|
|
turn: (inboxID) => failedTool(inboxID).filter((event) => event.type !== "session.tool.progress"),
|
|
renderTool: (part) => {
|
|
rendered.push(part)
|
|
return Promise.resolve()
|
|
},
|
|
renderToolError: (part) => {
|
|
failed.push(part)
|
|
return Promise.resolve()
|
|
},
|
|
})
|
|
|
|
expect(rendered).toMatchObject([
|
|
{
|
|
id: "call_failed_tool",
|
|
state: {
|
|
status: "completed",
|
|
metadata: { checkpoint: 1 },
|
|
content: [{ type: "text", text: "partial output" }],
|
|
},
|
|
},
|
|
])
|
|
expect(failed).toMatchObject([
|
|
{
|
|
id: "call_failed_tool",
|
|
state: {
|
|
status: "error",
|
|
metadata: { checkpoint: 1 },
|
|
content: [{ type: "text", text: "partial output" }],
|
|
error: { message: "tool failed" },
|
|
},
|
|
},
|
|
])
|
|
})
|
|
|
|
test("keeps failed tool partial output out of the JSON tool_use shape", async () => {
|
|
const output = await capture({ format: "json", turn: failedTool })
|
|
const events = output.stdout
|
|
.split("\n")
|
|
.filter(Boolean)
|
|
.map((line) => JSON.parse(line))
|
|
|
|
expect(events).toHaveLength(1)
|
|
expect(events[0]).toMatchObject({
|
|
type: "tool_use",
|
|
part: {
|
|
type: "tool",
|
|
id: "call_failed_tool",
|
|
tool: "shell",
|
|
state: {
|
|
status: "error",
|
|
input: { command: "printf partial && false" },
|
|
error: "tool failed",
|
|
},
|
|
},
|
|
})
|
|
expect(events[0].part.state.output).toBeUndefined()
|
|
expect(events[0].part.state.metadata.metadata).toBeUndefined()
|
|
expect(events[0].part.state.metadata.content).toBeUndefined()
|
|
expect(output.stderr).toBe("")
|
|
})
|
|
|
|
test("waits for a background shell and streams the reply it wakes", async () => {
|
|
const lists: ExperimentalJobListOutput[] = [[shellJob("sh_train", "python train.py")], []]
|
|
const output = await capture({
|
|
format: "json",
|
|
jobs: () => lists.shift() ?? [],
|
|
turn: (id) => [
|
|
prompted(id),
|
|
settled(),
|
|
{
|
|
id: "evt_text",
|
|
created: 2,
|
|
type: "session.text.ended",
|
|
durable: { aggregateID: "ses_1", seq: 2, version: 1 },
|
|
data: { sessionID: "ses_1", assistantMessageID: "msg_2", ordinal: 0, text: "Training finished." },
|
|
},
|
|
settled(),
|
|
],
|
|
})
|
|
expect(output.stdout).toContain("Training finished.")
|
|
})
|
|
|
|
test("waits for a background shell owned by a subagent", async () => {
|
|
const lists: ExperimentalJobListOutput[] = [[{ ...shellJob("sh_test", "pytest"), sessionID: "ses_child" }], []]
|
|
const sdk = await run({
|
|
jobs: () => lists.shift() ?? [],
|
|
turn: (id) => [
|
|
prompted(id),
|
|
settled(),
|
|
{
|
|
id: "evt_child_done",
|
|
created: 2,
|
|
type: "session.execution.succeeded",
|
|
durable: { aggregateID: "ses_child", seq: 1, version: 1 },
|
|
data: { sessionID: "ses_child" },
|
|
},
|
|
],
|
|
})
|
|
expect(sdk.experimental.job.list).toHaveBeenCalledWith({ sessionID: "ses_1", descendants: true })
|
|
expect(lists).toEqual([])
|
|
})
|
|
|
|
test("a recovered step failure exits 0, reports its retry once, and still waits for background work", async () => {
|
|
const lists: ExperimentalJobListOutput[] = [[shellJob("sh_train", "python train.py")], []]
|
|
const retry = { attempt: 1, error: { type: "provider.transport", message: "Recovered transport error" } }
|
|
const output = await capture({
|
|
format: "json",
|
|
jobs: () => lists.shift() ?? [],
|
|
messages: recoveredMessages("succeeded"),
|
|
turn: (id) => [
|
|
prompted(id),
|
|
stepStarted(),
|
|
stepFailed("Recovered transport error"),
|
|
{
|
|
id: "evt_retry",
|
|
created: 3,
|
|
type: "session.retry.scheduled",
|
|
durable: { aggregateID: "ses_1", seq: 3, version: 1 },
|
|
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", at: 2003, ...retry },
|
|
},
|
|
stepStarted(),
|
|
settled(),
|
|
{
|
|
id: "evt_text",
|
|
created: 4,
|
|
type: "session.text.ended",
|
|
durable: { aggregateID: "ses_1", seq: 4, version: 1 },
|
|
data: { sessionID: "ses_1", assistantMessageID: "msg_2", ordinal: 0, text: "Training finished." },
|
|
},
|
|
settled(),
|
|
],
|
|
})
|
|
const events = jsonLines(output.stdout)
|
|
expect(output.exitCode).toBe(0)
|
|
expect(events.filter((event) => event.type === "error" || event.type === "retry")).toEqual([
|
|
{ type: "retry", timestamp: 3, sessionID: "ses_1", messageID: "msg_assistant", nextRetryAt: 2003, ...retry },
|
|
])
|
|
expect(
|
|
events.filter((event) => event.part?.messageID === "msg_assistant" && event.type === "step_start"),
|
|
).toHaveLength(1)
|
|
expect(events.find((event) => event.type === "background_wait")).toMatchObject({
|
|
jobs: [{ id: "sh_train", kind: "shell" }],
|
|
})
|
|
expect(output.stdout).toContain("Training finished.")
|
|
expect(lists).toEqual([])
|
|
})
|
|
|
|
for (const outcome of ["failed", "interrupted"] as const) {
|
|
test(`a final ${outcome} outcome exits 1 with one error when the live event is dropped`, async () => {
|
|
const output = await capture({
|
|
format: "json",
|
|
turn: (id) => [prompted(id)],
|
|
// Without the failed step, so only the idle marker can report the outcome.
|
|
messages: (id) => recoveredMessages(outcome)(id).filter((message) => message.id !== "msg_assistant"),
|
|
wait: () => Promise.resolve(),
|
|
})
|
|
expect(output.exitCode).toBe(1)
|
|
expect(jsonLines(output.stdout).filter((event) => event.type === "error")).toHaveLength(1)
|
|
})
|
|
}
|
|
|
|
test("only the first idle marker after the prompt decides the outcome", async () => {
|
|
const output = await capture({
|
|
format: "json",
|
|
turn: (id) => [prompted(id)],
|
|
wait: () => Promise.resolve(),
|
|
messages: (messageID) => [
|
|
idleMessage("failed", "msg_later_idle"),
|
|
{ id: "msg_other_prompt", type: "user", text: "another client", time: { created: 6 } },
|
|
...recoveredMessages("succeeded")(messageID),
|
|
idleMessage("failed", "msg_previous_idle"),
|
|
],
|
|
})
|
|
expect(output.exitCode).toBe(0)
|
|
expect(output.stdout).not.toContain('"type":"error"')
|
|
})
|
|
|
|
test("a prompt left in the inbox by a failure before promotion does not hang", async () => {
|
|
const output = await capture({
|
|
format: "json",
|
|
turn: () => [executionFailed("instructions unavailable")],
|
|
messages: () => [],
|
|
inbox: (sessionID) => (sessionID === "ses_1" ? [{ id: "msg_prompt" } as SessionInboxListOutput[number]] : []),
|
|
})
|
|
expect(output.exitCode).toBe(1)
|
|
expect(jsonLines(output.stdout)).toEqual([
|
|
expect.objectContaining({
|
|
type: "error",
|
|
error: expect.objectContaining({ message: "instructions unavailable" }),
|
|
}),
|
|
])
|
|
})
|
|
|
|
test("keeps waiting when a woken reply moves from the inbox to running between reads", async () => {
|
|
// The wake leaves the inbox as it is read, and its reply runs until the next check.
|
|
let wake: "queued" | "running" | "done" = "queued"
|
|
const output = await capture({
|
|
format: "json",
|
|
inbox: () => {
|
|
if (wake === "queued") wake = "running"
|
|
return []
|
|
},
|
|
active: (): SessionActiveOutput => {
|
|
if (wake !== "running") return {}
|
|
wake = "done"
|
|
return { ses_1: { type: "running" } }
|
|
},
|
|
turn: (id) => [
|
|
prompted(id),
|
|
settled(),
|
|
{
|
|
id: "evt_text",
|
|
created: 2,
|
|
type: "session.text.ended",
|
|
durable: { aggregateID: "ses_1", seq: 2, version: 1 },
|
|
data: { sessionID: "ses_1", assistantMessageID: "msg_2", ordinal: 0, text: "Shell finished." },
|
|
},
|
|
settled(),
|
|
],
|
|
})
|
|
expect(output.stdout).toContain("Shell finished.")
|
|
})
|
|
|
|
test("fails the run when a background state read fails instead of treating it as done", async () => {
|
|
await expect(
|
|
run({
|
|
turn: (id) => [prompted(id), settled()],
|
|
active: () => {
|
|
throw new Error("state unavailable")
|
|
},
|
|
}),
|
|
).rejects.toThrow("state unavailable")
|
|
})
|
|
|
|
test("treats a server without the job list endpoint as having no jobs", async () => {
|
|
const output = await capture({
|
|
turn: (id) => [prompted(id), settled()],
|
|
jobs: () => {
|
|
throw new ClientError("UnsupportedContentType", { detail: "text/plain" })
|
|
},
|
|
})
|
|
expect(output.exitCode).toBe(0)
|
|
expect(output.stderr).toBe("")
|
|
})
|
|
|
|
test("does not wait on a job started before this prompt", async () => {
|
|
// A dev server left running by an earlier turn, as with --continue.
|
|
const output = await capture({
|
|
turn: (id) => [prompted(id), settled()],
|
|
jobs: () => [{ ...shellJob("sh_dev", "bun dev"), time: { started: 0 } }],
|
|
})
|
|
expect(output.exitCode).toBe(0)
|
|
expect(output.stderr).not.toContain("waiting for")
|
|
})
|
|
|
|
test("cancelling while waiting on background work stops the Session and this prompt's jobs", async () => {
|
|
const controller = new AbortController()
|
|
const old = { ...shellJob("sh_dev", "bun dev"), time: { started: 0 } }
|
|
const all = [old, shellJob("sh_train", "python train.py"), subagentJob]
|
|
// After the cancel reads and stops this prompt's jobs, only the older dev server is left.
|
|
let reads = 0
|
|
const running = run({
|
|
signal: controller.signal,
|
|
turn: (id) => [prompted(id), settled()],
|
|
jobs: () => (controller.signal.aborted && reads++ > 0 ? [old] : all),
|
|
})
|
|
expect(await Promise.race([running.then(() => "exited"), Bun.sleep(20).then(() => "waiting")])).toBe("waiting")
|
|
controller.abort()
|
|
const sdk = await running
|
|
expect(sdk.session.interrupt).toHaveBeenCalledWith({ sessionID: "ses_1" })
|
|
expect(sdk.session.interrupt).toHaveBeenCalledWith({ sessionID: "ses_child" })
|
|
expect(sdk.shell.remove).toHaveBeenCalledWith({ id: "sh_train", location: { directory: "/work tree" } })
|
|
expect(sdk.shell.remove).not.toHaveBeenCalledWith(expect.objectContaining({ id: "sh_dev" }))
|
|
})
|
|
|
|
test("cancelling mid-turn interrupts the Session and ignores the transport errors that follow", async () => {
|
|
const controller = new AbortController()
|
|
const closed = Promise.withResolvers<void>()
|
|
const output = capture({
|
|
signal: controller.signal,
|
|
turn: (id) => [prompted(id)],
|
|
// The private server shuts down while the idle wait is open.
|
|
wait: () => closed.promise,
|
|
})
|
|
await Bun.sleep(10)
|
|
controller.abort()
|
|
closed.reject(new ClientError("Transport", { detail: "ECONNRESET" }))
|
|
expect(await output).toMatchObject({ stdout: "", stderr: "", exitCode: 0 })
|
|
})
|
|
})
|