mini: add reconnect, forms, and shared targets (#37811)
Centralize session target resolution for mini and noninteractive run paths. Recover from transport drops, replace questions with forms, and keep tool/catalog state location-scoped with live progress and theme discovery.
This commit is contained in:
parent
71cb419570
commit
925c2423de
65 changed files with 5631 additions and 4292 deletions
|
|
@ -91,6 +91,31 @@ export default defineScript({
|
|||
if (!resized.includes("drive-mini-tool-output")) throw new Error("resize replay lost shell tool output")
|
||||
await Bun.write(path.join(snapshots, "03-resize-replay.txt"), resized)
|
||||
|
||||
llm.queue(
|
||||
llm.toolCall({
|
||||
index: 0,
|
||||
id: "mini-question",
|
||||
name: "question",
|
||||
input: {
|
||||
questions: [
|
||||
{
|
||||
header: "Drive form",
|
||||
question: "Choose the Mini Form answer",
|
||||
options: [{ label: "Accepted", description: "Continue the run" }],
|
||||
multiple: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
llm.finish("tool-calls"),
|
||||
)
|
||||
llm.queue(llm.text("drive mini form complete"))
|
||||
await tmux(["send-keys", "-t", session, "-l", "exercise the form"])
|
||||
await tmux(["send-keys", "-H", "-t", session, "0d"])
|
||||
await waitForPane(session, "Choose the Mini Form answer", 20_000)
|
||||
await tmux(["send-keys", "-H", "-t", session, "0d"])
|
||||
await waitForPane(session, "drive mini form complete", 20_000)
|
||||
|
||||
llm.queue(
|
||||
llm.toolCall({
|
||||
index: 0,
|
||||
|
|
|
|||
|
|
@ -145,11 +145,11 @@ describe("Mini CLI host", () => {
|
|||
expect(process.listenerCount("SIGUSR2")).toBe(sigusr2)
|
||||
})
|
||||
|
||||
test("passes paths, platform, timing, and diagnostic context", async () => {
|
||||
test("passes frontend host capabilities", async () => {
|
||||
const directory = await root()
|
||||
const input = host({ stdin: stream(true), cleanup() {} }, directory)
|
||||
|
||||
expect(input.paths).toEqual({ home: directory, state: directory, log: directory })
|
||||
expect(input.paths).toEqual({ home: directory })
|
||||
expect(input.platform).toBe(process.platform)
|
||||
expect(typeof input.files.readText).toBe("function")
|
||||
const file = path.join(directory, "attachment.txt")
|
||||
|
|
@ -157,7 +157,6 @@ describe("Mini CLI host", () => {
|
|||
expect(await input.files.readText(pathToFileURL(file).href)).toBe("attachment contents")
|
||||
expect(typeof input.startup.showTiming).toBe("boolean")
|
||||
expect(typeof input.startup.now()).toBe("number")
|
||||
expect(input.diagnostics).toMatchObject({ pid: process.pid, cwd: directory })
|
||||
})
|
||||
|
||||
test("merges, clears, and repairs persisted model variants", async () => {
|
||||
|
|
@ -186,6 +185,9 @@ describe("Mini CLI host", () => {
|
|||
variant: { "openai/gpt-4.1": "low" },
|
||||
})
|
||||
|
||||
await Bun.write(file, JSON.stringify({ variant: { "openai/gpt-5": "default" } }))
|
||||
expect(await input.preferences.resolveVariant(model)).toBeUndefined()
|
||||
|
||||
await Bun.write(file, "{")
|
||||
await input.preferences.saveVariant(model, "high")
|
||||
expect(await Bun.file(file).json()).toEqual({ variant: { "openai/gpt-5": "high" } })
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { ClientError, OpenCode } from "@opencode-ai/client/promise"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import path from "node:path"
|
||||
import { mergeInput as mergeInteractiveInput } from "../src/mini"
|
||||
import { mergeInput as mergeNonInteractiveInput, parseRunModel, pickRunModel } from "../src/run/run"
|
||||
import { createMiniConnection, mergeInput as mergeInteractiveInput, resolveMiniTarget } from "../src/mini"
|
||||
import { mergeInput as mergeNonInteractiveInput, parseRunModel } from "../src/run/run"
|
||||
import { parseSessionTargetModel } from "../src/session-target"
|
||||
|
||||
async function cli(args: string[]) {
|
||||
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
|
||||
|
|
@ -24,26 +26,91 @@ describe("mini command", () => {
|
|||
expect(mergeInteractiveInput("from stdin", "from flag")).toBe("from stdin\nfrom flag")
|
||||
})
|
||||
|
||||
test("constructs a fresh authenticated client for a replacement endpoint", async () => {
|
||||
const authorization: Array<string | null> = []
|
||||
const initial = Bun.serve({
|
||||
port: 0,
|
||||
fetch() {
|
||||
return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid })
|
||||
},
|
||||
})
|
||||
const replacement = Bun.serve({
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
authorization.push(request.headers.get("authorization"))
|
||||
return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid })
|
||||
},
|
||||
})
|
||||
const controller = new AbortController()
|
||||
let signal: AbortSignal | undefined
|
||||
|
||||
try {
|
||||
const connection = createMiniConnection({
|
||||
endpoint: { url: initial.url.toString() },
|
||||
reconnect: async (next) => {
|
||||
signal = next
|
||||
return {
|
||||
url: replacement.url.toString(),
|
||||
auth: { type: "basic", username: "replacement", password: "secret" },
|
||||
}
|
||||
},
|
||||
})
|
||||
const client = await connection.reconnect?.(controller.signal)
|
||||
if (!client) throw new Error("Expected a replacement client")
|
||||
await client.health.get()
|
||||
|
||||
expect(client).not.toBe(connection.sdk)
|
||||
expect(signal).toBe(controller.signal)
|
||||
expect(authorization).toEqual([`Basic ${btoa("replacement:secret")}`])
|
||||
expect(createMiniConnection({ endpoint: { url: initial.url.toString() } }).reconnect).toBeUndefined()
|
||||
} finally {
|
||||
initial.stop(true)
|
||||
replacement.stop(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("re-resolves a managed target when the endpoint moves before transport construction", async () => {
|
||||
const initial = OpenCode.make({ baseUrl: "https://initial.opencode.test" })
|
||||
const replacement = OpenCode.make({ baseUrl: "https://replacement.opencode.test" })
|
||||
const controller = new AbortController()
|
||||
const seen: (typeof initial)[] = []
|
||||
let reconnects = 0
|
||||
|
||||
const result = await resolveMiniTarget({
|
||||
sdk: initial,
|
||||
reconnect: async (signal) => {
|
||||
expect(signal).toBe(controller.signal)
|
||||
reconnects++
|
||||
if (reconnects === 1) throw new Error("service still moving")
|
||||
return replacement
|
||||
},
|
||||
signal: controller.signal,
|
||||
resolve: async (sdk) => {
|
||||
seen.push(sdk)
|
||||
if (sdk === initial) throw new ClientError("Transport")
|
||||
return "ses-replacement"
|
||||
},
|
||||
})
|
||||
|
||||
expect(seen).toEqual([initial, replacement])
|
||||
expect(reconnects).toBe(2)
|
||||
expect(result).toEqual({ sdk: replacement, value: "ses-replacement" })
|
||||
})
|
||||
|
||||
test("merges non-interactive argument and stdin input", () => {
|
||||
expect(mergeNonInteractiveInput("from args", "from stdin")).toBe("from args\nfrom stdin")
|
||||
expect(mergeNonInteractiveInput(undefined, "from stdin")).toBe("from stdin")
|
||||
})
|
||||
|
||||
test("applies a variant to a resumed session's model", () => {
|
||||
expect(
|
||||
pickRunModel(
|
||||
undefined,
|
||||
"high",
|
||||
{ providerID: "session-provider", modelID: "session-model" },
|
||||
{ providerID: "default-provider", modelID: "default-model" },
|
||||
),
|
||||
).toEqual({ providerID: "session-provider", modelID: "session-model" })
|
||||
})
|
||||
|
||||
test("parses model variants from the model reference", () => {
|
||||
expect(JSON.stringify(parseRunModel("openrouter/openai/gpt-5#high"))).toBe(
|
||||
JSON.stringify({ model: { providerID: "openrouter", modelID: "openai/gpt-5" }, variant: "high" }),
|
||||
)
|
||||
expect(parseSessionTargetModel("openrouter/openai/gpt-5#high")).toEqual({
|
||||
providerID: "openrouter",
|
||||
id: "openai/gpt-5",
|
||||
variant: "high",
|
||||
})
|
||||
})
|
||||
|
||||
test("is registered in the preview CLI", async () => {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||
import { OpenCode, type EventSubscribeOutput } from "@opencode-ai/client/promise"
|
||||
import { OpenCode, type EventSubscribeOutput, type SessionMessageAssistantTool } from "@opencode-ai/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)
|
||||
|
|
@ -18,8 +19,8 @@ function form(id: string, sessionID: string): FormInfo {
|
|||
}
|
||||
}
|
||||
|
||||
function formCreated(info: FormInfo): V2Event {
|
||||
return { id: `evt_${info.id}`, created: 0, type: "form.created", data: { form: info } }
|
||||
function formCreated(info: FormInfo, eventLocation = location): V2Event {
|
||||
return { id: `evt_${info.id}`, created: 0, type: "form.created", location: eventLocation, data: { form: info } }
|
||||
}
|
||||
|
||||
function prompted(inputID: string): V2Event {
|
||||
|
|
@ -92,6 +93,64 @@ function executionFailed(message: string): V2Event {
|
|||
}
|
||||
}
|
||||
|
||||
function failedTool(inputID: string): V2Event[] {
|
||||
return [
|
||||
prompted(inputID),
|
||||
{
|
||||
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",
|
||||
callID: "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",
|
||||
callID: "call_failed_tool",
|
||||
input: { command: "printf partial && false" },
|
||||
executed: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "evt_failed_tool_progress",
|
||||
created: 3,
|
||||
type: "session.tool.progress",
|
||||
durable: { aggregateID: "ses_1", seq: 3, version: 1 },
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_failed_tool",
|
||||
callID: "call_failed_tool",
|
||||
structured: { checkpoint: 1 },
|
||||
content: [{ type: "text", text: "partial output" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "evt_failed_tool_terminal",
|
||||
created: 4,
|
||||
type: "session.tool.failed",
|
||||
durable: { aggregateID: "ses_1", seq: 4, version: 1 },
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_failed_tool",
|
||||
callID: "call_failed_tool",
|
||||
error: { type: "unknown", message: "tool failed" },
|
||||
executed: true,
|
||||
},
|
||||
},
|
||||
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: {
|
||||
|
|
@ -100,6 +159,9 @@ async function run(input: {
|
|||
attached?: boolean
|
||||
format?: "default" | "json"
|
||||
compatibility?: "v1"
|
||||
cancel?: (input: { sessionID: string; formID: string }) => Promise<void>
|
||||
renderTool?: (part: SessionMessageAssistantTool) => Promise<void>
|
||||
renderToolError?: (part: SessionMessageAssistantTool) => Promise<void>
|
||||
}) {
|
||||
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
|
||||
const values: V2Event[] = [{ id: "evt_connected", type: "server.connected", data: {} }]
|
||||
|
|
@ -119,10 +181,18 @@ async function run(input: {
|
|||
spyOn(sdk.event, "subscribe").mockImplementation(() => stream)
|
||||
spyOn(sdk.permission, "list").mockImplementation(() => ok([]) as never)
|
||||
spyOn(sdk.question, "list").mockImplementation(() => ok([]) as never)
|
||||
spyOn(sdk.question, "reject").mockImplementation(() => ok(undefined) as never)
|
||||
spyOn(sdk.form, "list").mockImplementation(
|
||||
(request) => ok(input.pendingForms?.filter((item) => item.sessionID === request.sessionID) ?? []) as never,
|
||||
)
|
||||
spyOn(sdk.form, "cancel").mockImplementation(() => ok(undefined) as never)
|
||||
spyOn(sdk.form.request, "list").mockImplementation(
|
||||
() =>
|
||||
ok({
|
||||
location: { ...location, project: { id: "proj_1", directory: location.directory } },
|
||||
data: input.pendingForms?.filter((item) => item.sessionID === "global") ?? [],
|
||||
}) as never,
|
||||
)
|
||||
spyOn(sdk.form, "cancel").mockImplementation((request) => (input.cancel?.(request) ?? ok(undefined)) as never)
|
||||
spyOn(sdk.session, "prompt").mockImplementation((request) => {
|
||||
const messageID = request.id ?? "msg_prompt"
|
||||
values.push(...input.turn(messageID))
|
||||
|
|
@ -133,6 +203,7 @@ async function run(input: {
|
|||
await runNonInteractivePrompt({
|
||||
client: sdk,
|
||||
sessionID: "ses_1",
|
||||
location,
|
||||
message: "hello",
|
||||
files: [],
|
||||
thinking: false,
|
||||
|
|
@ -140,8 +211,8 @@ async function run(input: {
|
|||
auto: false,
|
||||
attached: input.attached ?? false,
|
||||
compatibility: input.compatibility,
|
||||
renderTool: () => Promise.resolve(),
|
||||
renderToolError: () => Promise.resolve(),
|
||||
renderTool: input.renderTool ?? (() => Promise.resolve()),
|
||||
renderToolError: input.renderToolError ?? (() => Promise.resolve()),
|
||||
})
|
||||
return sdk
|
||||
}
|
||||
|
|
@ -180,9 +251,20 @@ describe("runNonInteractivePrompt", () => {
|
|||
// which must not leave the consume loop waiting forever.
|
||||
turn: () => [formCreated(form("frm_live", "global")), settled("interrupted")],
|
||||
})
|
||||
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" })
|
||||
const globalOptions = {
|
||||
headers: {
|
||||
"x-opencode-directory": "%2Fwork%20tree",
|
||||
"x-opencode-workspace": "wrk_1",
|
||||
},
|
||||
}
|
||||
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }, globalOptions)
|
||||
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
|
||||
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" })
|
||||
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" }, globalOptions)
|
||||
expect(sdk.form.request.list).toHaveBeenCalledWith({
|
||||
location: { directory: "/work tree", workspace: "wrk_1" },
|
||||
})
|
||||
expect(sdk.question.list).not.toHaveBeenCalled()
|
||||
expect(sdk.question.reject).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("attach mode cancels only session-owned forms", async () => {
|
||||
|
|
@ -192,9 +274,12 @@ describe("runNonInteractivePrompt", () => {
|
|||
turn: (messageID) => [formCreated(form("frm_live", "global")), prompted(messageID), settled()],
|
||||
})
|
||||
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
|
||||
expect(sdk.form.list).not.toHaveBeenCalledWith({ sessionID: "global" })
|
||||
expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" })
|
||||
expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" })
|
||||
expect(sdk.form.request.list).not.toHaveBeenCalled()
|
||||
expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }, expect.anything())
|
||||
expect(sdk.form.cancel).not.toHaveBeenCalledWith(
|
||||
{ sessionID: "global", formID: "frm_pending_global" },
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
|
||||
test("V1 JSON output flushes step_start before an unrelated step failure", async () => {
|
||||
|
|
@ -250,4 +335,69 @@ describe("runNonInteractivePrompt", () => {
|
|||
|
||||
expect(output).toEqual({ stdout: "", stderr: "" })
|
||||
})
|
||||
|
||||
test("renders native failed tool output before the terminal error", async () => {
|
||||
const rendered: SessionMessageAssistantTool[] = []
|
||||
const failed: SessionMessageAssistantTool[] = []
|
||||
await capture({
|
||||
turn: failedTool,
|
||||
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",
|
||||
structured: { checkpoint: 1 },
|
||||
content: [{ type: "text", text: "partial output" }],
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(failed).toMatchObject([
|
||||
{
|
||||
id: "call_failed_tool",
|
||||
state: {
|
||||
status: "error",
|
||||
structured: { checkpoint: 1 },
|
||||
content: [{ type: "text", text: "partial output" }],
|
||||
error: { message: "tool failed" },
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps failed tool partial output out of the explicit V1 JSON bridge shape", async () => {
|
||||
const output = await capture({ compatibility: "v1", 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",
|
||||
callID: "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.structured).toBeUndefined()
|
||||
expect(events[0].part.state.metadata.content).toBeUndefined()
|
||||
expect(output.stderr).toBe("")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
87
packages/cli/test/session-target.test.ts
Normal file
87
packages/cli/test/session-target.test.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||
import { OpenCode, type LocationGetOutput, type ModelRef, type SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { resolveSessionTarget, SessionTargetMutationError } from "../src/session-target"
|
||||
|
||||
function location(directory: string, workspaceID?: string): LocationGetOutput {
|
||||
return { directory, workspaceID, project: { id: "project", directory } }
|
||||
}
|
||||
|
||||
function session(id: string, directory: string, workspaceID?: string, model?: ModelRef): SessionInfo {
|
||||
return {
|
||||
id,
|
||||
projectID: "project",
|
||||
title: id,
|
||||
location: { directory, workspaceID },
|
||||
model,
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 1 },
|
||||
}
|
||||
}
|
||||
|
||||
const prepare = async (input: { model: ModelRef | undefined; agent: string | undefined }) => ({
|
||||
model: input.model,
|
||||
agent: input.agent,
|
||||
})
|
||||
|
||||
afterEach(() => mock.restore())
|
||||
|
||||
describe("session target resolver", () => {
|
||||
test("adopts an explicit Session location and model", async () => {
|
||||
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
|
||||
const selected = session("ses_resume", "/session", "work_1", { providerID: "openai", id: "gpt-5" })
|
||||
spyOn(client.session, "get").mockResolvedValue(selected)
|
||||
spyOn(client.location, "get").mockResolvedValue(location("/session", "work_1"))
|
||||
|
||||
const target = await resolveSessionTarget({ client, session: selected.id, prepare })
|
||||
expect(target).toMatchObject({
|
||||
session: { id: "ses_resume" },
|
||||
location: { directory: "/session", workspaceID: "work_1" },
|
||||
model: { providerID: "openai", id: "gpt-5" },
|
||||
resume: true,
|
||||
})
|
||||
})
|
||||
|
||||
test("paginates to continue the exact implicit workspace", async () => {
|
||||
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
|
||||
spyOn(client.location, "get").mockResolvedValue(location("/project"))
|
||||
const explicit = Array.from({ length: 50 }, (_, index) => session(`ses_${index}`, "/project", `work_${index}`))
|
||||
const list = spyOn(client.session, "list")
|
||||
.mockResolvedValueOnce({ data: explicit, cursor: { next: "page_2" } })
|
||||
.mockResolvedValueOnce({ data: [session("ses_implicit", "/project")], cursor: {} })
|
||||
|
||||
const target = await resolveSessionTarget({ client, location: { directory: "/project" }, continue: true, prepare })
|
||||
expect(list).toHaveBeenCalledTimes(2)
|
||||
expect(target.session.id).toBe("ses_implicit")
|
||||
})
|
||||
|
||||
test("prepares a fresh Session at the server Location before creation", async () => {
|
||||
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
|
||||
const order: string[] = []
|
||||
spyOn(client.location, "get").mockResolvedValue(location("/server", "work_1"))
|
||||
const create = spyOn(client.session, "create").mockImplementation(async (input) => {
|
||||
order.push("create")
|
||||
expect(input).toMatchObject({ agent: "prepared", location: { directory: "/server", workspaceID: "work_1" } })
|
||||
return session("ses_fresh", "/server", "work_1")
|
||||
})
|
||||
|
||||
await resolveSessionTarget({
|
||||
client,
|
||||
agent: "requested",
|
||||
prepare: async (input) => {
|
||||
order.push("prepare")
|
||||
expect(input.location.workspaceID).toBe("work_1")
|
||||
return { model: input.model, agent: "prepared" }
|
||||
},
|
||||
})
|
||||
expect(create).toHaveBeenCalledTimes(1)
|
||||
expect(order).toEqual(["prepare", "create"])
|
||||
})
|
||||
|
||||
test("does not retry an ambiguous Session creation", async () => {
|
||||
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
|
||||
spyOn(client.location, "get").mockResolvedValue(location("/project"))
|
||||
spyOn(client.session, "create").mockRejectedValue(new Error("connection closed after create"))
|
||||
await expect(resolveSessionTarget({ client, prepare })).rejects.toBeInstanceOf(SessionTargetMutationError)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue