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
commit 64e4f6f91b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 763 additions and 77 deletions

View file

@ -175,14 +175,18 @@ function parseSlashCommand(text: string, commands: RunCommand[] | undefined) {
return { type: "pending" as const }
}
if (!commands.some((item) => item.name === head.name)) {
const item = commands.find((entry) => entry.name === head.name)
if (!item) {
return { type: "none" as const }
}
return { type: "command" as const, command: { name: head.name, arguments: head.arguments } }
return {
type: "command" as const,
command: { name: head.name, arguments: head.arguments, ...(item.source ? { source: item.source } : {}) },
}
}
function selectedCommand(text: string, command: RunPrompt["command"]) {
export function selectedCommand(text: string, command: RunPrompt["command"], commands?: RunCommand[]) {
if (!command) {
return
}
@ -192,9 +196,14 @@ function selectedCommand(text: string, command: RunPrompt["command"]) {
return
}
// Bound drafts (e.g. the skill picker) may predate or omit the catalog
// source; resolve it at submit time so routing never degrades to a plain
// command for a skill entry.
const source = command.source ?? commands?.find((item) => item.name === command.name)?.source
return {
name: command.name,
arguments: head.arguments,
...(source ? { source } : {}),
}
}
@ -1178,7 +1187,7 @@ export function createPromptState(input: PromptInput): PromptState {
return
}
const command = next.mode === "shell" ? undefined : selectedCommand(next.text, next.command)
const command = next.mode === "shell" ? undefined : selectedCommand(next.text, next.command, input.commands())
if (!command && next.mode !== "shell" && isExitCommand(next.text)) {
input.onExit()
return

View file

@ -781,6 +781,7 @@ export function RunFooterView(props: RunFooterViewProps) {
command: {
name,
arguments: "",
source: "skill",
},
})
closePanel()

View file

@ -665,7 +665,9 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
(row) => row.commit.kind !== "user" || row.commit.messageID !== prompt.messageID,
)
}
includeFiles = false
// Shell and skill turns never send CLI file attachments; keep them
// pending for the next prompt-shaped turn.
if (prompt.mode !== "shell" && prompt.command?.source !== "skill") includeFiles = false
} catch (error) {
if (signal.aborted || footer.isClosed) {
return

View file

@ -77,6 +77,15 @@ type Wait = {
onVisibleOutput?: (anchor: LocalReplayAnchor) => void
}
// One active session.shell call. The HTTP response is the completion signal;
// callID correlates the live shell events once shell.started is observed, and
// abort cancels the blocking request when the user interrupts the turn.
type ShellWait = {
callID?: string
resolve: () => void
abort: () => void
}
type RunV2Event = V2Event
type PromptFilePart = Extract<RunPromptPart, { type: "file" }>
@ -99,6 +108,11 @@ type State = {
projectedReasoning: Map<string, string>
tools: Map<string, ToolState>
finishedTools: Set<string>
skillMessages: Set<string>
shellCommands: Map<string, string>
shellStarted: Set<string>
shellEnded: Set<string>
shellWait?: ShellWait
wait?: Wait
connected: boolean
closed: boolean
@ -179,10 +193,71 @@ function promptFileSource(part: PromptFilePart) {
}
}
function promptFiles(next: SessionTurnInput) {
return next.prompt.parts.flatMap((part) =>
part.type === "file"
? [
{
uri: part.url,
name: part.filename,
source: promptFileSource(part),
},
]
: [],
)
}
function promptAgents(next: SessionTurnInput) {
return next.prompt.parts.flatMap((part) =>
part.type === "agent"
? [
{
name: part.name,
source: part.source ? { start: part.source.start, end: part.source.end, text: part.source.value } : undefined,
},
]
: [],
)
}
function streamPartKey(messageID: string, partID: string) {
return `${messageID}\u0000${partID}`
}
// Matches the commit shapes the legacy session-data reducer produced for direct
// shell calls: one "start" commit rendering `$ command` and one "progress"
// commit rendering the merged output (see toolEntryBody in tool.ts).
function shellCommit(
callID: string,
command: string,
next: { text: string; phase: "start" | "progress"; toolState: "running" | "completed" },
): StreamCommit {
return {
kind: "tool",
source: "tool",
partID: `shell:${callID}`,
tool: "bash",
shell: { callID, command },
...next,
}
}
// session.shell resolves after the command settled server-side; the matching
// live shell.ended event usually lands within the same tick, but hold the turn
// briefly so the output commit renders inside it.
const SHELL_OUTPUT_GRACE_MS = 1500
function skillCommit(messageID: string, name: string): StreamCommit {
return {
kind: "system",
source: "system",
messageID,
partID: `skill:${messageID}`,
text: `→ Skill "${name}"`,
phase: "start",
}
}
async function resolveSelectedModel(input: StreamInput, next: Pick<SessionTurnInput, "model" | "variant" | "signal">) {
if (next.model) return { providerID: next.model.providerID, id: next.model.modelID, variant: next.variant }
if (!next.variant) return
@ -213,6 +288,10 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
projectedReasoning: new Map(),
tools: new Map(),
finishedTools: new Set(),
skillMessages: new Set(),
shellCommands: new Map(),
shellStarted: new Set(),
shellEnded: new Set(),
connected: false,
closed: false,
initial: true,
@ -314,6 +393,40 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
write([{ kind: "user", source: "system", text: message.text, phase: "start", messageID: message.id }])
return
}
if (message.type === "skill") {
if (state.wait?.messageID === message.id) state.wait.promoted = true
if (!render || state.skillMessages.has(message.id)) {
state.skillMessages.add(message.id)
return
}
state.skillMessages.add(message.id)
write([skillCommit(message.id, message.name)])
return
}
if (message.type === "shell") {
state.shellCommands.set(message.callID, message.command)
const completed = message.time.completed !== undefined
if (!render) {
// Suppressed history: mark settled shells rendered so live redelivery
// stays silent. A still-running shell stays unmarked and renders in
// full when its live shell.ended event arrives.
if (completed) {
state.shellStarted.add(message.callID)
state.shellEnded.add(message.callID)
}
return
}
if (!state.shellStarted.has(message.callID)) {
state.shellStarted.add(message.callID)
write([shellCommit(message.callID, message.command, { text: "running shell", phase: "start", toolState: "running" })])
}
if (completed && !state.shellEnded.has(message.callID)) {
state.shellEnded.add(message.callID)
write([shellCommit(message.callID, message.command, { text: message.output, phase: "progress", toolState: "completed" })])
}
if (completed && state.shellWait?.callID === message.callID) state.shellWait.resolve()
return
}
if (message.type !== "assistant") return
state.messageIDs.add(message.id)
for (const item of message.content) {
@ -412,6 +525,45 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
write([], { phase: "running", status: "assistant responding" })
return
}
if (event.type === "skill.activated") {
const messageID = event.id.replace(/^evt_/, "msg_")
if (state.wait) state.wait.promoted = true
if (state.skillMessages.has(messageID)) return
state.skillMessages.add(messageID)
write([skillCommit(messageID, event.data.name)])
return
}
if (event.type === "shell.started") {
state.shellCommands.set(event.data.callID, event.data.command)
const wait = state.shellWait
if (wait && wait.callID === undefined) wait.callID = event.data.callID
if (state.shellStarted.has(event.data.callID)) return
state.shellStarted.add(event.data.callID)
write([shellCommit(event.data.callID, event.data.command, { text: "running shell", phase: "start", toolState: "running" })], {
phase: "running",
status: "running shell",
})
return
}
if (event.type === "shell.ended") {
const command = state.shellCommands.get(event.data.callID) ?? ""
const commits: StreamCommit[] = []
if (!state.shellStarted.has(event.data.callID)) {
state.shellStarted.add(event.data.callID)
if (command) commits.push(shellCommit(event.data.callID, command, { text: "running shell", phase: "start", toolState: "running" }))
}
if (!state.shellEnded.has(event.data.callID)) {
state.shellEnded.add(event.data.callID)
commits.push(shellCommit(event.data.callID, command, { text: event.data.output, phase: "progress", toolState: "completed" }))
}
const wait = state.shellWait
// An unset callID means shell.started has not been observed yet (event
// delivery lag); mini serializes its own shells, so adopt this ended.
const owned = wait !== undefined && (wait.callID === undefined || wait.callID === event.data.callID)
write(commits, owned || state.wait ? undefined : { phase: "idle", status: "" })
if (owned) wait.resolve()
return
}
if (event.type === "text.delta") {
const key = streamPartKey(event.data.assistantMessageID, event.data.textID)
const projected = state.projectedText.get(key)
@ -673,12 +825,129 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
controller.signal.removeEventListener("abort", abortReady)
}
const runShellTurn = async (next: SessionTurnInput) => {
if (state.wait || state.shellWait) throw new Error("prompt already running")
if (!state.connected) throw new Error("Event stream is reconnecting")
const abort = new AbortController()
const onAbort = () => abort.abort()
next.signal?.addEventListener("abort", onAbort, { once: true })
let rendered!: () => void
const output = new Promise<void>((resolve) => {
rendered = resolve
})
const active: ShellWait = { resolve: rendered, abort: () => abort.abort() }
state.shellWait = active
input.trace?.write("send.shell", { sessionID: input.sessionID, command: next.prompt.text })
write([], { phase: "running", status: "running shell" })
try {
await input.sdk.v2.session.shell(
{ sessionID: input.sessionID, command: next.prompt.text },
{ throwOnError: true, signal: abort.signal },
)
await Promise.race([output, wait(SHELL_OUTPUT_GRACE_MS, abort.signal)])
} catch (error) {
if (abort.signal.aborted) return
throw error
} finally {
next.signal?.removeEventListener("abort", onAbort)
if (state.shellWait === active) state.shellWait = undefined
}
}
// Shared settlement scaffolding for prompt-shaped turns: registers the wait,
// wires interruption, sends, then blocks until the live settled event (or a
// hydration pass over an idle session) resolves it.
const runTurnWait = async (
next: SessionTurnInput,
messageID: string,
turn: { promoted?: boolean; send: () => Promise<unknown> },
) => {
let resolve!: () => void
let reject!: (error: unknown) => void
const done = new Promise<void>((ok, fail) => {
resolve = ok
reject = fail
})
const active: Wait = {
messageID,
promoted: turn.promoted === true,
interrupted: false,
failureRendered: false,
resolve,
reject,
onVisibleOutput: next.onVisibleOutput,
}
state.wait = active
const interrupt = () => {
active.interrupted = true
void input.sdk.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
}
next.signal?.addEventListener("abort", interrupt, { once: true })
try {
await turn.send()
await done
} catch (error) {
if (state.wait === active) state.wait = undefined
if (next.signal?.aborted) return
throw error
} finally {
next.signal?.removeEventListener("abort", interrupt)
}
}
return {
async runPromptTurn(next) {
if (next.prompt.mode === "shell") throw new Error("Shell is not yet available for current Session transcripts")
if (next.prompt.command) throw new Error("Commands are not yet available for current Session transcripts")
if (state.wait) throw new Error("prompt already running")
if (next.prompt.mode === "shell") {
await runShellTurn(next)
return
}
if (state.wait || state.shellWait) throw new Error("prompt already running")
if (!state.connected) throw new Error("Event stream is reconnecting")
const messageID = next.prompt.messageID
if (!messageID) throw new Error("Prompt message ID is required")
const command = next.prompt.command
if (command?.source === "skill") {
input.trace?.write("send.skill", { sessionID: input.sessionID, messageID, skill: command.name })
await runTurnWait(next, messageID, {
send: () =>
input.sdk.v2.session.skill(
{ sessionID: input.sessionID, id: messageID, skill: command.name },
{ throwOnError: true, signal: next.signal },
),
})
return
}
if (command) {
const selected = await resolveSelectedModel(input, next)
if (next.variant && !selected) throw new Error("Cannot select a variant before selecting a model")
// Agent and model ride the command payload; the server switches only
// when the command itself does not pin them.
const files = [
...(next.includeFiles ? next.files : []).map((file) => ({ uri: file.url, name: file.filename })),
...promptFiles(next),
]
const agents = promptAgents(next)
input.trace?.write("send.command", { sessionID: input.sessionID, messageID, command: command.name })
await runTurnWait(next, messageID, {
send: () =>
input.sdk.v2.session.command(
{
sessionID: input.sessionID,
id: messageID,
command: command.name,
arguments: command.arguments,
agent: next.agent,
model: selected,
files: files.length ? files : undefined,
agents: agents.length ? agents : undefined,
delivery: "steer",
},
{ throwOnError: true, signal: next.signal },
),
})
return
}
if (next.agent) {
await input.sdk.v2.session.switchAgent(
@ -695,78 +964,41 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
)
const prepared = await Promise.all((next.includeFiles ? next.files : []).map(prepareFile))
const promptFiles = next.prompt.parts.flatMap((part) =>
part.type === "file"
? [
{
uri: part.url,
name: part.filename,
source: promptFileSource(part),
const attachments = [
...prepared.flatMap((file) => (file.attachment ? [file.attachment] : [])),
...promptFiles(next),
]
const agents = promptAgents(next)
input.trace?.write("send.prompt", { sessionID: input.sessionID, messageID })
await runTurnWait(next, messageID, {
send: () =>
input.sdk.v2.session.prompt(
{
sessionID: input.sessionID,
id: messageID,
prompt: {
text: [
next.prompt.text,
...prepared.flatMap((file) => (file.text ? [file.text] : [])),
].join("\n\n"),
files: attachments.length ? attachments : undefined,
agents: agents.length ? agents : undefined,
},
]
: [],
)
const attachments = [...prepared.flatMap((file) => (file.attachment ? [file.attachment] : [])), ...promptFiles]
const agents = next.prompt.parts.flatMap((part) =>
part.type === "agent"
? [
{
name: part.name,
source: part.source
? { start: part.source.start, end: part.source.end, text: part.source.value }
: undefined,
},
]
: [],
)
const messageID = next.prompt.messageID
if (!messageID) throw new Error("Prompt message ID is required")
let resolve!: () => void
let reject!: (error: unknown) => void
const done = new Promise<void>((done, fail) => {
resolve = done
reject = fail
})
const active: Wait = {
messageID,
promoted: false,
interrupted: false,
failureRendered: false,
resolve,
reject,
onVisibleOutput: next.onVisibleOutput,
}
state.wait = active
const interrupt = () => {
active.interrupted = true
void input.sdk.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
}
next.signal?.addEventListener("abort", interrupt, { once: true })
try {
input.trace?.write("send.prompt", { sessionID: input.sessionID, messageID })
await input.sdk.v2.session.prompt(
{
sessionID: input.sessionID,
id: messageID,
prompt: {
text: [next.prompt.text, ...prepared.flatMap((file) => (file.text ? [file.text] : []))].join("\n\n"),
files: attachments.length ? attachments : undefined,
agents: agents.length ? agents : undefined,
delivery: "steer",
},
delivery: "steer",
},
{ throwOnError: true, signal: next.signal },
)
await done
} catch (error) {
if (state.wait === active) state.wait = undefined
if (next.signal?.aborted) return
throw error
} finally {
next.signal?.removeEventListener("abort", interrupt)
}
{ throwOnError: true, signal: next.signal },
),
})
},
async interruptActiveTurn() {
// A running shell holds no drain, so session.interrupt cannot reach it;
// abort the blocking request instead. The server-side command keeps its
// own lifecycle and simply loses its waiter.
const shell = state.shellWait
if (shell) {
shell.abort()
return
}
if (state.wait) state.wait.interrupted = true
await input.sdk.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
},
@ -787,6 +1019,10 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
state.projectedReasoning.clear()
state.tools.clear()
state.finishedTools.clear()
state.skillMessages.clear()
state.shellCommands.clear()
state.shellStarted.clear()
state.shellEnded.clear()
state.errors.clear()
await hydrate({ render: true, reuseVisibleWait: false })
} finally {

View file

@ -93,6 +93,8 @@ export type RunPrompt = {
command?: {
name: string
arguments: string
// Catalog source of the matched slash entry ("skill" routes to session.skill).
source?: string
}
}

View file

@ -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()
}

View file

@ -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())