Browse Source

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

Simon Klee 1 month ago
parent
commit
64e4f6f91b

+ 13 - 4
packages/opencode/src/cli/cmd/run/footer.prompt.tsx

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

+ 1 - 0
packages/opencode/src/cli/cmd/run/footer.view.tsx

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

+ 3 - 1
packages/opencode/src/cli/cmd/run/runtime.ts

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

+ 307 - 71
packages/opencode/src/cli/cmd/run/stream-v2.transport.ts

@@ -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]
-      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 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 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 {

+ 2 - 0
packages/opencode/src/cli/cmd/run/types.ts

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

+ 48 - 1
packages/opencode/test/cli/run/footer.view.test.tsx

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

+ 389 - 0
packages/opencode/test/cli/run/stream-v2.transport.test.ts

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