diff --git a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx b/packages/opencode/src/cli/cmd/run/footer.prompt.tsx index 90efdc5695..9d10a5266a 100644 --- a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx +++ b/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 diff --git a/packages/opencode/src/cli/cmd/run/footer.view.tsx b/packages/opencode/src/cli/cmd/run/footer.view.tsx index 245a24816d..a7c1321494 100644 --- a/packages/opencode/src/cli/cmd/run/footer.view.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.view.tsx @@ -781,6 +781,7 @@ export function RunFooterView(props: RunFooterViewProps) { command: { name, arguments: "", + source: "skill", }, }) closePanel() diff --git a/packages/opencode/src/cli/cmd/run/runtime.ts b/packages/opencode/src/cli/cmd/run/runtime.ts index 8f3704fd41..e340a89cc3 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.ts +++ b/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 diff --git a/packages/opencode/src/cli/cmd/run/stream-v2.transport.ts b/packages/opencode/src/cli/cmd/run/stream-v2.transport.ts index 614a960634..c15ed48e5b 100644 --- a/packages/opencode/src/cli/cmd/run/stream-v2.transport.ts +++ b/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 @@ -99,6 +108,11 @@ type State = { projectedReasoning: Map tools: Map finishedTools: Set + skillMessages: Set + shellCommands: Map + shellStarted: Set + shellEnded: Set + 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) { 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 { + 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((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 }, + ) => { + let resolve!: () => void + let reject!: (error: unknown) => void + const done = new Promise((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 - 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((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 } }) +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() } diff --git a/packages/opencode/test/cli/run/stream-v2.transport.test.ts b/packages/opencode/test/cli/run/stream-v2.transport.test.ts index 8bf429e500..e43e88f934 100644 --- a/packages/opencode/test/cli/run/stream-v2.transport.test.ts +++ b/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[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[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[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())