diff --git a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx b/packages/opencode/src/cli/cmd/run/footer.prompt.tsx index bed3009274..5658d8d34d 100644 --- a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.prompt.tsx @@ -88,6 +88,7 @@ type PromptInput = { export type PromptState = { placeholder: Accessor bindings: Accessor + shell: Accessor visible: Accessor options: Accessor selected: Accessor @@ -110,9 +111,14 @@ function clonePrompt(prompt: RunPrompt): RunPrompt { return { text: prompt.text, parts: structuredClone(prompt.parts), + ...(prompt.mode ? { mode: prompt.mode } : {}), } } +function emptyPrompt(shell: boolean): RunPrompt { + return shell ? { text: "", parts: [], mode: "shell" } : { text: "", parts: [] } +} + function removeLineRange(input: string) { const hash = input.lastIndexOf("#") return hash === -1 ? input : input.slice(0, hash) @@ -274,7 +280,14 @@ export function RunPromptBody(props: { export function createPromptState(input: PromptInput): PromptState { const keys = createMemo(() => promptKeys(input.keybinds)) const bindings = createMemo(() => keys().bindings) + const [shell, setShell] = createSignal(false) const placeholder = createMemo(() => { + if (shell()) { + return new StyledText([ + bg(input.theme().surface)(fg(input.theme().muted)('Run a command... "git status"')), + ]) + } + if (!input.state().first) { return "" } @@ -301,6 +314,11 @@ export function createPromptState(input: PromptInput): PromptState { const [query, setQuery] = createSignal("") const visible = createMemo(() => mode() !== false) + const setShellMode = (value: boolean) => { + setShell(value) + draft = value ? { ...draft, mode: "shell" } : { text: draft.text, parts: structuredClone(draft.parts) } + } + const width = createMemo(() => Math.max(20, input.width() - 8)) const agents = createMemo(() => { return input @@ -577,6 +595,7 @@ export function createPromptState(input: PromptInput): PromptState { const restore = (value: RunPrompt, cursor = Bun.stringWidth(value.text)) => { draft = clonePrompt(value) + setShell(value.mode === "shell") if (!area || area.isDestroyed) { return } @@ -596,7 +615,7 @@ export function createPromptState(input: PromptInput): PromptState { clearParts() hide() - draft = { text: "", parts: [] } + draft = emptyPrompt(shell()) if (!area || area.isDestroyed) { return } @@ -606,7 +625,7 @@ export function createPromptState(input: PromptInput): PromptState { } const replaceDraft = (text: string) => { - draft = { text, parts: [] } + draft = shell() ? { text, parts: [], mode: "shell" } : { text, parts: [] } if (!area || area.isDestroyed) { return } @@ -614,7 +633,7 @@ export function createPromptState(input: PromptInput): PromptState { hide() area.setText(text) clearParts() - draft = { text: area.plainText, parts: [] } + draft = shell() ? { text: area.plainText, parts: [], mode: "shell" } : { text: area.plainText, parts: [] } area.cursorOffset = Math.min(Bun.stringWidth(text), Bun.stringWidth(area.plainText)) scheduleRows() area.focus() @@ -705,10 +724,16 @@ export function createPromptState(input: PromptInput): PromptState { } syncParts() - draft = { - text: area.plainText, - parts: structuredClone(parts), - } + draft = shell() + ? { + text: area.plainText, + parts: structuredClone(parts), + mode: "shell", + } + : { + text: area.plainText, + parts: structuredClone(parts), + } } const push = (value: RunPrompt) => { @@ -943,6 +968,35 @@ export function createPromptState(input: PromptInput): PromptState { } } + if ( + key.name === "!" && + !shell() && + !event.ctrl && + !event.meta && + !event.super && + area && + !area.isDestroyed && + area.cursorOffset === 0 + ) { + event.preventDefault() + setShellMode(true) + return + } + + if (shell() && !visible()) { + if (key.name === "escape") { + event.preventDefault() + setShellMode(false) + return + } + + if (key.name === "backspace" && area && !area.isDestroyed && area.cursorOffset === 0) { + event.preventDefault() + setShellMode(false) + return + } + } + if (promptHit(keys().clear, key)) { const handled = requestExit() if (handled) { @@ -1028,23 +1082,28 @@ export function createPromptState(input: PromptInput): PromptState { return } - if (isExitCommand(next.text)) { + if (next.mode !== "shell" && isExitCommand(next.text)) { input.onExit() return } - const parsed = isNewCommand(next.text) ? undefined : parseSlashCommand(next.text, input.commands()) + const parsed = next.mode === "shell" || isNewCommand(next.text) ? undefined : parseSlashCommand(next.text, input.commands()) if (parsed?.type === "pending") { input.onStatus("loading commands") return } const submit = parsed?.type === "command" ? { ...next, command: parsed.command } : next + const shellMode = next.mode === "shell" resetDraft() queueMicrotask(async () => { if (await input.onSubmit(submit)) { push(next) + if (shellMode) { + setShellMode(false) + draft = emptyPrompt(false) + } return } @@ -1121,6 +1180,7 @@ export function createPromptState(input: PromptInput): PromptState { return { placeholder, bindings, + shell, visible, options, selected: menu.selected, diff --git a/packages/opencode/src/cli/cmd/run/footer.view.tsx b/packages/opencode/src/cli/cmd/run/footer.view.tsx index e1a028b7e9..bc0a3490b1 100644 --- a/packages/opencode/src/cli/cmd/run/footer.view.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.view.tsx @@ -265,6 +265,7 @@ export function RunFooterView(props: RunFooterViewProps) { onRows: props.onRows, onStatus: props.onStatus, }) + const shell = createMemo(() => prompt() && composer.shell()) const menu = createMemo(() => prompt() && composer.visible()) createEffect(() => { @@ -487,18 +488,20 @@ export function RunFooterView(props: RunFooterViewProps) { paddingTop={1} > - {props.agent} - - - {props.state().model} + {shell() ? "Shell" : props.agent} + + + {props.state().model} + + @@ -629,19 +632,30 @@ export function RunFooterView(props: RunFooterViewProps) { flexShrink={0} justifyContent="flex-end" > - 0}> - - {queue()} queued - - - 0}> - - {usage()} - - - 0 && hints().command}> - - {command()} commands + + 0}> + + {queue()} queued + + + 0}> + + {usage()} + + + 0 && hints().command}> + + {command()} commands + + + + } + > + + esc exit shell mode diff --git a/packages/opencode/src/cli/cmd/run/prompt.shared.ts b/packages/opencode/src/cli/cmd/run/prompt.shared.ts index 0da787cb3c..2dda26bae1 100644 --- a/packages/opencode/src/cli/cmd/run/prompt.shared.ts +++ b/packages/opencode/src/cli/cmd/run/prompt.shared.ts @@ -65,11 +65,12 @@ export function promptCopy(prompt: RunPrompt): RunPrompt { return { text: prompt.text, parts: structuredClone(prompt.parts), + ...(prompt.mode ? { mode: prompt.mode } : {}), } } export function promptSame(a: RunPrompt, b: RunPrompt): boolean { - return a.text === b.text && JSON.stringify(a.parts) === JSON.stringify(b.parts) + return a.mode === b.mode && a.text === b.text && JSON.stringify(a.parts) === JSON.stringify(b.parts) } function promptKey(binding: ReturnType[number]): PromptInfo | undefined { diff --git a/packages/opencode/src/cli/cmd/run/runtime.queue.ts b/packages/opencode/src/cli/cmd/run/runtime.queue.ts index d82b9e19e9..79be71cadf 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.queue.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.queue.ts @@ -102,7 +102,7 @@ export async function runPromptQueue(input: QueueInput): Promise { continue } - if (isNewCommand(prompt.text)) { + if (prompt.mode !== "shell" && isNewCommand(prompt.text)) { emit( { type: "queue", @@ -167,9 +167,11 @@ export async function runPromptQueue(input: QueueInput): Promise { break } - const commit = { kind: "user", text: prompt.text, phase: "start", source: "system" } as const - input.trace?.write("ui.commit", commit) - input.footer.append(commit) + if (prompt.mode !== "shell") { + const commit = { kind: "user", text: prompt.text, phase: "start", source: "system" } as const + input.trace?.write("ui.commit", commit) + input.footer.append(commit) + } input.onSend?.(prompt) if (state.closed) { @@ -234,7 +236,7 @@ export async function runPromptQueue(input: QueueInput): Promise { return } - if (isExitCommand(prompt.text)) { + if (prompt.mode !== "shell" && isExitCommand(prompt.text)) { input.footer.close() return } @@ -249,7 +251,7 @@ export async function runPromptQueue(input: QueueInput): Promise { queue: state.queue.length, }, ) - if (isNewCommand(prompt.text)) { + if (prompt.mode !== "shell" && isNewCommand(prompt.text)) { drain() return } diff --git a/packages/opencode/src/cli/cmd/run/session-data.ts b/packages/opencode/src/cli/cmd/run/session-data.ts index d3d61dd15e..4a3a49fb83 100644 --- a/packages/opencode/src/cli/cmd/run/session-data.ts +++ b/packages/opencode/src/cli/cmd/run/session-data.ts @@ -61,13 +61,20 @@ type SessionCommit = StreamCommit // - text: part ID → full accumulated text so far // - sent: part ID → byte offset of last flushed text (for incremental output) // - end: part IDs whose time.end has arrived (part is finished) +// - shell: shell call ID → chosen transcript source for direct shell calls // - echo: message ID → bash outputs to strip from the next assistant chunk +type ShellCall = { + source: "shell" | "tool" + command?: string +} + export type SessionData = { includeUserText: boolean announced: boolean ids: Set tools: Set call: Map + shell: Map permissions: PermissionRequest[] questions: QuestionRequest[] role: Map @@ -104,6 +111,7 @@ export function createSessionData( ids: new Set(), tools: new Set(), call: new Map(), + shell: new Map(), permissions: [], questions: [], role: new Map(), @@ -621,6 +629,87 @@ function toolCommit( } } +function shellPartID(callID: string): string { + return `shell:${callID}` +} + +function claimShell(data: SessionData, callID: string, source: ShellCall["source"], command?: string): ShellCall { + const current = data.shell.get(callID) + if (current) { + if (command && !current.command) { + current.command = command + } + + return current + } + + const next = { + source, + ...(command ? { command } : {}), + } satisfies ShellCall + data.shell.set(callID, next) + return next +} + +function bashCommand(part: ToolPart): string | undefined { + if (part.tool !== "bash") { + return undefined + } + + const input = part.state.input + if (!input || typeof input !== "object" || Array.isArray(input)) { + return undefined + } + + const command = Reflect.get(input, "command") + return typeof command === "string" ? command : undefined +} + +function shellCommit( + input: { + callID: string + command: string + }, + next: Pick, +): SessionCommit { + return { + kind: "tool", + source: "tool", + partID: shellPartID(input.callID), + tool: "bash", + shell: input, + ...next, + } +} + +function startShell(callID: string, command: string): SessionCommit { + return shellCommit( + { + callID, + command, + }, + { + text: "running shell", + phase: "start", + toolState: "running", + }, + ) +} + +function doneShell(callID: string, command: string, output: string): SessionCommit { + return shellCommit( + { + callID, + command, + }, + { + text: output, + phase: "progress", + toolState: "completed", + }, + ) +} + function startTool(part: ToolPart): SessionCommit { return toolCommit(part, { text: toolStatus(part), @@ -681,6 +770,53 @@ export function reduceSessionData(input: SessionDataInput): SessionDataOutput { const data = input.data const event = input.event + if (event.type === "session.next.shell.started") { + if (event.properties.sessionID !== input.sessionID) { + return out(data, commits) + } + + const shell = claimShell(data, event.properties.callID, "shell", event.properties.command) + if (shell.source !== "shell") { + return out(data, commits) + } + + const partID = shellPartID(event.properties.callID) + if (data.ids.has(partID) || data.tools.has(partID)) { + return out(data, commits, patch({ status: "running shell" })) + } + + data.tools.add(partID) + commits.push(startShell(event.properties.callID, shell.command ?? event.properties.command)) + return out(data, commits, patch({ status: "running shell" })) + } + + if (event.type === "session.next.shell.ended") { + if (event.properties.sessionID !== input.sessionID) { + return out(data, commits) + } + + const shell = claimShell(data, event.properties.callID, "shell") + if (shell.source !== "shell") { + return out(data, commits) + } + + const partID = shellPartID(event.properties.callID) + const seen = data.tools.has(partID) + const command = shell.command ?? "" + data.tools.delete(partID) + if (data.ids.has(partID)) { + return out(data, commits) + } + + if (!seen && command) { + commits.push(startShell(event.properties.callID, command)) + } + + data.ids.add(partID) + commits.push(doneShell(event.properties.callID, command, event.properties.output)) + return out(data, commits) + } + if (event.type === "message.updated") { if (event.properties.sessionID !== input.sessionID) { return out(data, commits) @@ -782,6 +918,11 @@ export function reduceSessionData(input: SessionDataInput): SessionDataOutput { if (part.type === "tool") { const view = syncPermission(data, part) ?? syncQuestion(data, part) + if (part.tool === "bash" && part.callID) { + if (claimShell(data, part.callID, "tool", bashCommand(part)).source === "shell") { + return out(data, commits, view) + } + } if (part.state.status === "running") { if (data.ids.has(part.id)) { diff --git a/packages/opencode/src/cli/cmd/run/stream.transport.ts b/packages/opencode/src/cli/cmd/run/stream.transport.ts index 1bb9aac52c..780032c1db 100644 --- a/packages/opencode/src/cli/cmd/run/stream.transport.ts +++ b/packages/opencode/src/cli/cmd/run/stream.transport.ts @@ -132,6 +132,8 @@ function sid(event: Event): string | undefined { } if ( + event.type === "session.next.shell.started" || + event.type === "session.next.shell.ended" || event.type === "permission.asked" || event.type === "permission.replied" || event.type === "question.asked" || @@ -513,6 +515,22 @@ function createLayer(input: StreamInput) { state.footerView = current } + const resolveShellAgent = Effect.fn("RunStreamTransport.resolveShellAgent")(function* (agent: string | undefined) { + if (agent) { + return agent + } + + const list = yield* Effect.promise(() => + input.sdk.app.agents(input.directory ? { directory: input.directory } : undefined, { throwOnError: true }), + ).pipe(Effect.map((item) => item.data ?? []), Effect.orElseSucceed(() => [])) + const next = list.find((item) => item.mode !== "subagent" && item.hidden !== true)?.name + if (next) { + return next + } + + return yield* Effect.fail(new Error("no primary agent available for shell mode")) + }) + const recoverQuestion = Effect.fn("RunStreamTransport.recoverQuestion")(function* (partID: string) { if (recovering.has(partID)) { return @@ -1005,7 +1023,46 @@ function createLayer(input: StreamInput) { ], } const command = next.prompt.command - const send = command + const send = next.prompt.mode === "shell" + ? Effect.sync(() => { + input.trace?.write("send.shell", { + sessionID: input.sessionID, + command: next.prompt.text, + }) + }).pipe( + Effect.andThen( + resolveShellAgent(next.agent).pipe( + Effect.flatMap((agent) => + Effect.promise(() => + input.sdk.session.shell( + { + sessionID: input.sessionID, + agent, + model: next.model, + command: next.prompt.text, + }, + { signal: turn.signal, throwOnError: true }, + ), + ), + ), + ).pipe( + Effect.tap(() => + Effect.sync(() => { + input.trace?.write("send.shell.ok", { + sessionID: input.sessionID, + }) + item.armed = true + item.live = true + }), + ), + Effect.flatMap(() => Deferred.succeed(item.done, undefined).pipe(Effect.ignore)), + Effect.catch((error) => Deferred.fail(item.done, error).pipe(Effect.ignore)), + Effect.forkIn(scope, { startImmediately: true }), + Effect.asVoid, + ), + ), + ) + : command ? Effect.sync(() => { input.trace?.write("send.command", { sessionID: input.sessionID, command: command.name }) }).pipe( diff --git a/packages/opencode/src/cli/cmd/run/tool.ts b/packages/opencode/src/cli/cmd/run/tool.ts index 3dab7aa8df..52b29528b2 100644 --- a/packages/opencode/src/cli/cmd/run/tool.ts +++ b/packages/opencode/src/cli/cmd/run/tool.ts @@ -35,7 +35,7 @@ import { webSearchProviderLabel, type WebSearchTool } from "@/tool/websearch" import type { WriteTool } from "@/tool/write" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import * as Locale from "@/util/locale" -import type { RunDiffStyle, RunEntryBody, StreamCommit, ToolSnapshot } from "./types" +import type { RunEntryBody, StreamCommit, ToolSnapshot } from "./types" export type ToolView = { output: boolean @@ -626,6 +626,10 @@ function scrollBashStart(p: ToolProps): string { const desc = p.input.description || "Shell" const wd = p.input.workdir ?? "" const dir = wd && wd !== "." ? toolPath(wd) : "" + if (cmd && desc === "Shell" && !dir) { + return `$ ${cmd}` + } + const title = dir && !desc.includes(dir) ? `${desc} in ${dir}` : desc if (!cmd) { @@ -1248,7 +1252,7 @@ function frame(part: ToolPart): ToolFrame { raw: "", name: part.tool, input: dict(state.input), - meta: dict(state.metadata), + meta: "metadata" in part.state ? dict(part.state.metadata) : {}, state, status: text(state.status), error: text(state.error), @@ -1261,7 +1265,7 @@ export function toolFrame(commit: StreamCommit, raw: string): ToolFrame { raw, name: commit.tool || commit.part?.tool || "tool", input: dict(state.input), - meta: dict(state.metadata), + meta: commit.part?.state && "metadata" in commit.part.state ? dict(commit.part.state.metadata) : {}, state, status: commit.toolState ?? text(state.status), error: (commit.toolError ?? "").trim(), @@ -1403,7 +1407,32 @@ function structuredBody(commit: StreamCommit, raw: string): RunEntryBody | undef } } +function shellOutput(command: string, raw: string): string | undefined { + const body = stripAnsi(raw).replace(/^\n+/, "").replace(/\n+$/, "") + if (!body) { + return undefined + } + + if (!command) { + return body + } + + return `\n${body}` +} + export function toolEntryBody(commit: StreamCommit, raw: string): RunEntryBody | undefined { + if (commit.shell) { + if (commit.phase === "start") { + return textBody(`$ ${commit.shell.command}`) + } + + if (commit.phase === "progress") { + return textBody(shellOutput(commit.shell.command, raw) ?? "") + } + + return undefined + } + const ctx = toolFrame(commit, raw) const view = toolView(ctx.name) diff --git a/packages/opencode/src/cli/cmd/run/types.ts b/packages/opencode/src/cli/cmd/run/types.ts index 3822223649..d16c9bc3bf 100644 --- a/packages/opencode/src/cli/cmd/run/types.ts +++ b/packages/opencode/src/cli/cmd/run/types.ts @@ -34,6 +34,7 @@ export type RunProvider = NonNullable { }) }) + test("renders command-only bash starts without the shell header", () => { + expect( + entryBody( + toolCommit({ + tool: "bash", + phase: "start", + toolState: "running", + text: "running shell", + state: { + status: "running", + input: { + command: "ls", + }, + time: { start: 1 }, + }, + }), + ), + ).toEqual({ + type: "text", + content: "$ ls", + }) + }) + + test("renders direct shell commits without a synthetic shell header", () => { + expect( + entryBody( + commit({ + kind: "tool", + text: "running shell", + phase: "start", + source: "tool", + tool: "bash", + partID: "shell:call-1", + toolState: "running", + shell: { + callID: "call-1", + command: "pwd", + }, + }), + ), + ).toEqual({ + type: "text", + content: "$ pwd", + }) + + expect( + entryBody( + commit({ + kind: "tool", + text: "/tmp/demo\n", + phase: "progress", + source: "tool", + tool: "bash", + partID: "shell:call-1", + toolState: "completed", + shell: { + callID: "call-1", + command: "pwd", + }, + }), + ), + ).toEqual({ + type: "text", + content: "\n/tmp/demo", + }) + }) + test("falls back to patch summary when apply_patch has no visible diff items", () => { expect( entryBody( diff --git a/packages/opencode/test/cli/run/runtime.queue.test.ts b/packages/opencode/test/cli/run/runtime.queue.test.ts index cc8b9d963e..5515787caf 100644 --- a/packages/opencode/test/cli/run/runtime.queue.test.ts +++ b/packages/opencode/test/cli/run/runtime.queue.test.ts @@ -60,8 +60,8 @@ function footer() { api, events, commits, - submit(text: string) { - const next = { text, parts: [] as RunPrompt["parts"] } + submit(text: string, mode?: RunPrompt["mode"]) { + const next = mode ? { text, parts: [] as RunPrompt["parts"], mode } : { text, parts: [] as RunPrompt["parts"] } for (const fn of [...prompts]) { fn(next) } @@ -137,6 +137,64 @@ describe("run runtime queue", () => { ]) }) + test("shell mode submits /exit as a shell command", async () => { + const ui = footer() + const seen: RunPrompt[] = [] + + const task = runPromptQueue({ + footer: ui.api, + run: async (input) => { + seen.push(input) + ui.api.close() + }, + }) + + ui.submit("/exit", "shell") + await task + + expect(seen).toEqual([{ text: "/exit", parts: [], mode: "shell" }]) + expect(ui.commits).toEqual([]) + }) + + test("shell mode submits /new instead of creating a session", async () => { + const ui = footer() + const seen: RunPrompt[] = [] + let created = 0 + + const task = runPromptQueue({ + footer: ui.api, + onNewSession: async () => { + created += 1 + }, + run: async (input) => { + seen.push(input) + ui.api.close() + }, + }) + + ui.submit("/new", "shell") + await task + + expect(created).toBe(0) + expect(seen).toEqual([{ text: "/new", parts: [], mode: "shell" }]) + expect(ui.commits).toEqual([]) + }) + + test("shell mode does not append a synthetic user row", async () => { + const ui = footer() + + const task = runPromptQueue({ + footer: ui.api, + run: async () => { + expect(ui.commits).toEqual([]) + ui.api.close() + }, + }) + + ui.submit("ls", "shell") + await task + }) + test("preserves whitespace for initial input", async () => { const ui = footer() const seen: string[] = [] diff --git a/packages/opencode/test/cli/run/session-data.test.ts b/packages/opencode/test/cli/run/session-data.test.ts index 439d988be9..705e0250de 100644 --- a/packages/opencode/test/cli/run/session-data.test.ts +++ b/packages/opencode/test/cli/run/session-data.test.ts @@ -326,6 +326,190 @@ describe("run session data", () => { ]) }) + test("renders direct shell mode from first-class shell events", () => { + let data = createSessionData() + const started = reduce(data, { + type: "session.next.shell.started", + properties: { + sessionID: "session-1", + timestamp: 1, + callID: "call-1", + command: "pwd", + }, + }) + + expect(started.commits).toEqual([ + expect.objectContaining({ + kind: "tool", + phase: "start", + partID: "shell:call-1", + tool: "bash", + shell: { + callID: "call-1", + command: "pwd", + }, + }), + ]) + + data = started.data + const ended = reduce(data, { + type: "session.next.shell.ended", + properties: { + sessionID: "session-1", + timestamp: 2, + callID: "call-1", + output: "/tmp/demo\n", + }, + }) + + expect(ended.commits).toEqual([ + expect.objectContaining({ + kind: "tool", + phase: "progress", + partID: "shell:call-1", + tool: "bash", + text: "/tmp/demo\n", + toolState: "completed", + shell: { + callID: "call-1", + command: "pwd", + }, + }), + ]) + }) + + test("suppresses legacy bash part updates once shell events claim the call", () => { + let data = reduce(createSessionData(), { + type: "session.next.shell.started", + properties: { + sessionID: "session-1", + timestamp: 1, + callID: "call-1", + command: "pwd", + }, + }).data + + expect( + reduce( + data, + tool({ + id: "tool-1", + messageID: "msg-1", + callID: "call-1", + tool: "bash", + state: { + status: "running", + input: { + command: "pwd", + }, + time: { start: 1 }, + }, + }), + ).commits, + ).toEqual([]) + + data = reduce(data, { + type: "session.next.shell.ended", + properties: { + sessionID: "session-1", + timestamp: 2, + callID: "call-1", + output: "/tmp/demo\n", + }, + }).data + + expect( + reduce( + data, + tool({ + id: "tool-1", + messageID: "msg-1", + callID: "call-1", + tool: "bash", + state: { + status: "completed", + input: { + command: "pwd", + }, + output: "/tmp/demo\n", + title: "", + metadata: { + output: "/tmp/demo\n", + description: "", + }, + time: { start: 1, end: 2 }, + }, + }), + ).commits, + ).toEqual([]) + }) + + test("suppresses shell events when the legacy bash part claimed the call first", () => { + let data = reduce( + createSessionData(), + tool({ + id: "tool-1", + messageID: "msg-1", + callID: "call-1", + tool: "bash", + state: { + status: "running", + input: { + command: "pwd", + }, + time: { start: 1 }, + }, + }), + ).data + + expect( + reduce(data, { + type: "session.next.shell.started", + properties: { + sessionID: "session-1", + timestamp: 1, + callID: "call-1", + command: "pwd", + }, + }).commits, + ).toEqual([]) + + data = reduce( + data, + tool({ + id: "tool-1", + messageID: "msg-1", + callID: "call-1", + tool: "bash", + state: { + status: "completed", + input: { + command: "pwd", + }, + output: "/tmp/demo\n", + title: "", + metadata: { + output: "/tmp/demo\n", + description: "", + }, + time: { start: 1, end: 2 }, + }, + }), + ).data + + expect( + reduce(data, { + type: "session.next.shell.ended", + properties: { + sessionID: "session-1", + timestamp: 2, + callID: "call-1", + output: "/tmp/demo\n", + }, + }).commits, + ).toEqual([]) + }) + test("synthesizes a glob start before an error when the running update is missed", () => { expect( reduce( diff --git a/packages/opencode/test/cli/run/subagent-data.test.ts b/packages/opencode/test/cli/run/subagent-data.test.ts index 8d2dad365b..e31136b22f 100644 --- a/packages/opencode/test/cli/run/subagent-data.test.ts +++ b/packages/opencode/test/cli/run/subagent-data.test.ts @@ -354,7 +354,7 @@ describe("run subagent data", () => { expect(visible(snapshot.details["child-1"]?.commits ?? [])).toEqual([ "› Inspect footer tabs", "_Thinking:_ planning next steps", - "# Shell\n$ git status --short", + "$ git status --short", "hello world", ]) expect(snapshot.permissions).toEqual([