cli: route run commands through v2 APIs (#35234)

This commit is contained in:
Simon Klee
2026-07-03 22:47:04 +02:00
committed by GitHub
parent 4551d27e82
commit fc223b21fe
7 changed files with 763 additions and 77 deletions
@@ -32,6 +32,7 @@ import type {
StreamCommit,
} from "@/cli/cmd/run/types"
import { RunQuestionBody } from "@/cli/cmd/run/footer.question"
import { selectedCommand } from "@/cli/cmd/run/footer.prompt"
import { RejectField } from "@/cli/cmd/run/footer.permission"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
@@ -832,6 +833,52 @@ test("direct footer slash autocomplete keeps a real skills command", async () =>
}
})
test("selectedCommand backfills the catalog source for bound drafts", () => {
const catalog = [command({ name: "opencode-ts", description: "TS skill", source: "skill" })]
// The skill picker binds `/name ` drafts; older drafts may lack source.
expect(selectedCommand("/opencode-ts fix it", { name: "opencode-ts", arguments: "" }, catalog)).toEqual({
name: "opencode-ts",
arguments: "fix it",
source: "skill",
})
// An explicit source wins without a catalog lookup.
expect(selectedCommand("/opencode-ts", { name: "opencode-ts", arguments: "", source: "skill" })).toEqual({
name: "opencode-ts",
arguments: "",
source: "skill",
})
// Plain commands stay untagged.
expect(selectedCommand("/deploy prod", { name: "deploy", arguments: "" }, [
command({ name: "deploy", description: "Deploy" }),
])).toEqual({ name: "deploy", arguments: "prod" })
})
test("direct footer tags skill slash submissions with their catalog source", async () => {
const submits: RunPrompt[] = []
const app = await renderFooter({
commands: [command({ name: "formatter", description: "Apply formatter fixes", source: "skill" })],
onSubmit(prompt) {
submits.push(prompt)
return true
},
})
try {
await app.renderOnce()
"/formatter src".split("").forEach((key) => app.mockInput.pressKey(key))
await app.renderOnce()
app.mockInput.pressEnter()
await app.renderOnce()
expect(submits).toEqual([
{ text: "/formatter src", parts: [], command: { name: "formatter", arguments: "src", source: "skill" } },
])
} finally {
app.cleanup()
}
})
// OpenTUI currently segfaults Bun while tearing down this composer-to-skill-panel transition.
// Re-enable after the upstream renderer teardown fix lands.
test.skip("direct footer skill picker inserts an editable bound skill command", async () => {
@@ -864,7 +911,7 @@ test.skip("direct footer skill picker inserts an editable bound skill command",
app.mockInput.pressEnter()
await app.renderOnce()
expect(submits).toEqual([{ text: "/new task", parts: [], command: { name: "new", arguments: "task" } }])
expect(submits).toEqual([{ text: "/new task", parts: [], command: { name: "new", arguments: "task", source: "skill" } }])
} finally {
app.cleanup()
}
@@ -1002,6 +1002,395 @@ describe("V2 mini transport", () => {
await transport.close()
})
test("runs a shell turn through v2.session.shell and renders live output", async () => {
const events = feed()
events.push(connected())
const client = sdk({ streams: [events] })
const ui = footer()
const transport = await createSessionTransport({
sdk: client,
sessionID: "ses_1",
thinking: false,
limits: () => ({}),
footer: ui.api,
})
let request: Parameters<OpencodeClient["v2"]["session"]["shell"]>[0] | undefined
spyOn(client.v2.session, "shell").mockImplementation((input) => {
request = input
queueMicrotask(() => {
events.push({
id: "evt_shell_start",
created: 0,
type: "shell.started",
durable: durable("ses_1"),
data: { sessionID: "ses_1", callID: "call_shell", command: "ls" },
})
events.push({
id: "evt_shell_end",
created: 0,
type: "shell.ended",
durable: durable("ses_1", 1),
data: { sessionID: "ses_1", callID: "call_shell", output: "file.txt" },
})
})
return ok(undefined) as never
})
await transport.runPromptTurn({
agent: undefined,
model: undefined,
variant: undefined,
prompt: { text: "ls", parts: [], mode: "shell" },
files: [],
includeFiles: true,
})
expect(request).toMatchObject({ sessionID: "ses_1", command: "ls" })
expect(ui.commits.filter((item) => item.shell)).toMatchObject([
{ phase: "start", tool: "bash", toolState: "running", shell: { callID: "call_shell", command: "ls" } },
{ phase: "progress", text: "file.txt", toolState: "completed", shell: { callID: "call_shell", command: "ls" } },
])
expect(ui.events).toContainEqual({ type: "stream.patch", patch: { phase: "running", status: "running shell" } })
await transport.close()
})
test("aborts an active shell turn without interrupting the session", async () => {
const events = feed()
events.push(connected())
const client = sdk({ streams: [events] })
const ui = footer()
const transport = await createSessionTransport({
sdk: client,
sessionID: "ses_1",
thinking: false,
limits: () => ({}),
footer: ui.api,
})
let started = false
let aborted = false
spyOn(client.v2.session, "shell").mockImplementation(
(_input, options) =>
new Promise((_, reject) => {
started = true
options?.signal?.addEventListener("abort", () => {
aborted = true
reject(new Error("aborted"))
})
}) as never,
)
const interrupted = spyOn(client.v2.session, "interrupt").mockImplementation(() => ok(undefined))
const turn = transport.runPromptTurn({
agent: undefined,
model: undefined,
variant: undefined,
prompt: { text: "sleep 100", parts: [], mode: "shell" },
files: [],
includeFiles: true,
})
while (!started) await Bun.sleep(0)
await transport.interruptActiveTurn()
await turn
expect(aborted).toBe(true)
expect(interrupted).not.toHaveBeenCalled()
await transport.close()
})
test("hydrates projected shell transcripts once and dedupes live redelivery", async () => {
const events = feed()
events.push(connected())
const client = sdk({
streams: [events],
messages: {
ses_1: [
{
id: "msg_shell",
type: "shell" as const,
callID: "call_1",
command: "ls",
output: "file.txt",
time: { created: 1, completed: 2 },
},
],
},
})
const ui = footer()
const transport = await createSessionTransport({
sdk: client,
sessionID: "ses_1",
thinking: false,
replay: true,
limits: () => ({}),
footer: ui.api,
})
events.push({
id: "evt_shell_end",
created: 0,
type: "shell.ended",
durable: durable("ses_1", 1),
data: { sessionID: "ses_1", callID: "call_1", output: "file.txt" },
})
await Bun.sleep(0)
await Bun.sleep(0)
expect(ui.commits.filter((item) => item.shell)).toMatchObject([
{ phase: "start", shell: { callID: "call_1", command: "ls" } },
{ phase: "progress", text: "file.txt", toolState: "completed" },
])
await transport.close()
})
test("routes command prompts through v2.session.command", async () => {
const events = feed()
events.push(connected())
const client = sdk({ streams: [events] })
const ui = footer()
const transport = await createSessionTransport({
sdk: client,
sessionID: "ses_1",
thinking: false,
limits: () => ({}),
footer: ui.api,
})
let request: Parameters<OpencodeClient["v2"]["session"]["command"]>[0] | undefined
spyOn(client.v2.session, "command").mockImplementation((input) => {
request = input
queueMicrotask(() => {
events.push({
id: "evt_prompted",
created: 0,
type: "prompt.promoted",
durable: durable("ses_1"),
data: {
sessionID: "ses_1",
inputID: "msg_cmd",
},
})
events.push({
id: "evt_settled",
created: 0,
type: "execution.settled",
data: { sessionID: "ses_1", outcome: "success" },
})
})
return ok({
data: {
admittedSeq: 1,
id: input.id ?? "msg_cmd",
sessionID: "ses_1",
prompt: { text: "evaluated template" },
delivery: "steer" as const,
timeCreated: 2,
},
})
})
await transport.runPromptTurn({
agent: "build",
model: { providerID: "test", modelID: "model" },
variant: undefined,
prompt: {
messageID: "msg_cmd",
text: "/deploy prod",
parts: [],
command: { name: "deploy", arguments: "prod" },
},
files: [],
includeFiles: true,
})
expect(request).toMatchObject({
sessionID: "ses_1",
id: "msg_cmd",
command: "deploy",
arguments: "prod",
agent: "build",
model: { providerID: "test", id: "model" },
delivery: "steer",
})
// Selection rides the command payload; no separate client-side switch.
expect(client.v2.session.switchAgent).not.toHaveBeenCalled()
expect(client.v2.session.switchModel).not.toHaveBeenCalled()
await transport.close()
})
test("routes skill prompts through v2.session.skill and settles without promotion", async () => {
const events = feed()
events.push(connected())
const client = sdk({ streams: [events] })
const ui = footer()
const transport = await createSessionTransport({
sdk: client,
sessionID: "ses_1",
thinking: false,
limits: () => ({}),
footer: ui.api,
})
let request: Parameters<OpencodeClient["v2"]["session"]["skill"]>[0] | undefined
const command = spyOn(client.v2.session, "command")
const prompt = spyOn(client.v2.session, "prompt")
spyOn(client.v2.session, "skill").mockImplementation((input) => {
request = input
queueMicrotask(() => {
events.push({
id: "evt_skill",
created: 0,
type: "skill.activated",
durable: durable("ses_1"),
data: {
sessionID: "ses_1",
name: input.skill ?? "tigerstyle",
text: "skill instructions",
},
})
events.push({
id: "evt_settled",
created: 0,
type: "execution.settled",
data: { sessionID: "ses_1", outcome: "success" },
})
})
return ok(undefined) as never
})
await transport.runPromptTurn({
agent: undefined,
model: undefined,
variant: undefined,
prompt: {
messageID: "msg_skill",
text: "/tigerstyle",
parts: [],
command: { name: "tigerstyle", arguments: "", source: "skill" },
},
files: [],
includeFiles: true,
})
expect(request).toMatchObject({ sessionID: "ses_1", id: "msg_skill", skill: "tigerstyle" })
expect(command).not.toHaveBeenCalled()
expect(prompt).not.toHaveBeenCalled()
expect(ui.commits).toContainEqual(
expect.objectContaining({ kind: "system", text: '→ Skill "tigerstyle"', messageID: "msg_skill" }),
)
await transport.close()
})
test("does not resolve a skill turn before the matching activation is observed", async () => {
const events = feed()
events.push(connected())
const client = sdk({ streams: [events] })
const ui = footer()
const transport = await createSessionTransport({
sdk: client,
sessionID: "ses_1",
thinking: false,
limits: () => ({}),
footer: ui.api,
})
let sent = false
spyOn(client.v2.session, "skill").mockImplementation(() => {
sent = true
return ok(undefined) as never
})
let done = false
const turn = transport
.runPromptTurn({
agent: undefined,
model: undefined,
variant: undefined,
prompt: {
messageID: "msg_skill",
text: "/tigerstyle",
parts: [],
command: { name: "tigerstyle", arguments: "", source: "skill" },
},
files: [],
includeFiles: true,
})
.then(() => {
done = true
})
while (!sent) await Bun.sleep(0)
events.push({
id: "evt_unrelated_settled",
created: 0,
type: "execution.settled",
data: { sessionID: "ses_1", outcome: "success" },
})
await Bun.sleep(0)
await Bun.sleep(0)
expect(done).toBe(false)
events.push({
id: "evt_skill",
created: 0,
type: "skill.activated",
durable: durable("ses_1"),
data: {
sessionID: "ses_1",
name: "tigerstyle",
text: "skill instructions",
},
})
events.push({
id: "evt_skill_settled",
created: 0,
type: "execution.settled",
data: { sessionID: "ses_1", outcome: "success" },
})
await turn
expect(done).toBe(true)
await transport.close()
})
test("hydrates skill activation messages once and dedupes live redelivery", async () => {
const events = feed()
events.push(connected())
const client = sdk({
streams: [events],
messages: {
ses_1: [
{
id: "msg_skill",
type: "skill" as const,
name: "tigerstyle",
text: "skill instructions",
time: { created: 2 },
},
],
},
})
const ui = footer()
const transport = await createSessionTransport({
sdk: client,
sessionID: "ses_1",
thinking: false,
replay: true,
limits: () => ({}),
footer: ui.api,
})
events.push({
id: "evt_skill",
created: 0,
type: "skill.activated",
durable: durable("ses_1"),
data: {
sessionID: "ses_1",
name: "tigerstyle",
text: "skill instructions",
},
})
await Bun.sleep(0)
await Bun.sleep(0)
expect(ui.commits.filter((item) => item.text === '→ Skill "tigerstyle"')).toHaveLength(1)
await transport.close()
})
test("discovers a live child session and tracks its tab and selected detail", async () => {
const events = feed()
events.push(connected())