cli: route run commands through v2 APIs (#35234)
This commit is contained in:
parent
d097cc8065
commit
64e4f6f91b
7 changed files with 763 additions and 77 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -781,6 +781,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||
command: {
|
||||
name,
|
||||
arguments: "",
|
||||
source: "skill",
|
||||
},
|
||||
})
|
||||
closePanel()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue