Compare commits

...

3 Commits

Author SHA1 Message Date
Simon Klee ba424de6c2 fix broken test 2026-05-19 10:24:05 +02:00
Simon Klee a838ff4c48 run: render direct shell calls from stream events
Consume the new session.next.shell.started/ended events instead so
direct shell calls render live, in order with the rest of the
stream, and without SDK fallback polling.

Track each call's source (shell event vs legacy bash tool part) so
whichever arrives first wins; the other is ignored to avoid double
commits when both representations reach the client.
2026-05-19 10:04:27 +02:00
Simon Klee cf3433334a run: add shell mode to prompt
Press `!` on an empty prompt to enter shell mode and run a command
through session.shell instead of sending a message
2026-05-19 10:04:27 +02:00
12 changed files with 665 additions and 47 deletions
@@ -88,6 +88,7 @@ type PromptInput = {
export type PromptState = {
placeholder: Accessor<StyledText | string>
bindings: Accessor<KeyBinding[]>
shell: Accessor<boolean>
visible: Accessor<boolean>
options: Accessor<PromptOption[]>
selected: Accessor<number>
@@ -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<Auto[]>(() => {
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,
@@ -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}
>
<text id="run-direct-footer-agent" fg={theme().highlight} wrapMode="none" truncate flexShrink={0}>
{props.agent}
</text>
<text
id="run-direct-footer-model"
fg={theme().text}
wrapMode="none"
truncate
flexGrow={1}
flexShrink={1}
>
{props.state().model}
{shell() ? "Shell" : props.agent}
</text>
<Show when={!shell()}>
<text
id="run-direct-footer-model"
fg={theme().text}
wrapMode="none"
truncate
flexGrow={1}
flexShrink={1}
>
{props.state().model}
</text>
</Show>
</box>
</Show>
</box>
@@ -629,19 +632,30 @@ export function RunFooterView(props: RunFooterViewProps) {
flexShrink={0}
justifyContent="flex-end"
>
<Show when={queue() > 0}>
<text id="run-direct-footer-queue" fg={theme().muted} wrapMode="none" truncate>
{queue()} queued
</text>
</Show>
<Show when={usage().length > 0}>
<text id="run-direct-footer-usage" fg={theme().muted} wrapMode="none" truncate>
{usage()}
</text>
</Show>
<Show when={command().length > 0 && hints().command}>
<text id="run-direct-footer-hint-command" fg={theme().text} wrapMode="none" truncate>
{command()} <span style={{ fg: theme().muted }}>commands</span>
<Show
when={shell()}
fallback={
<>
<Show when={queue() > 0}>
<text id="run-direct-footer-queue" fg={theme().muted} wrapMode="none" truncate>
{queue()} queued
</text>
</Show>
<Show when={usage().length > 0}>
<text id="run-direct-footer-usage" fg={theme().muted} wrapMode="none" truncate>
{usage()}
</text>
</Show>
<Show when={command().length > 0 && hints().command}>
<text id="run-direct-footer-hint-command" fg={theme().text} wrapMode="none" truncate>
{command()} <span style={{ fg: theme().muted }}>commands</span>
</text>
</Show>
</>
}
>
<text id="run-direct-footer-hint-shell" fg={theme().text} wrapMode="none" truncate>
esc <span style={{ fg: theme().muted }}>exit shell mode</span>
</text>
</Show>
</box>
@@ -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<typeof parseBindings>[number]): PromptInfo | undefined {
@@ -102,7 +102,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
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<void> {
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<void> {
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<void> {
queue: state.queue.length,
},
)
if (isNewCommand(prompt.text)) {
if (prompt.mode !== "shell" && isNewCommand(prompt.text)) {
drain()
return
}
@@ -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<string>
tools: Set<string>
call: Map<string, Dict>
shell: Map<string, ShellCall>
permissions: PermissionRequest[]
questions: QuestionRequest[]
role: Map<string, MessageRole>
@@ -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, "text" | "phase" | "toolState">,
): 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)) {
@@ -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(
+32 -3
View File
@@ -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<typeof BashTool>): 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)
@@ -34,6 +34,7 @@ export type RunProvider = NonNullable<Awaited<ReturnType<OpencodeClient["provide
export type RunPrompt = {
text: string
parts: RunPromptPart[]
mode?: "shell"
command?: {
name: string
arguments: string
@@ -302,6 +303,10 @@ export type StreamCommit = {
interrupted?: boolean
toolState?: StreamToolState
toolError?: string
shell?: {
callID: string
command: string
}
}
// The public contract between the stream transport / prompt queue and
@@ -359,6 +359,73 @@ describe("run entry body", () => {
})
})
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(
@@ -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[] = []
@@ -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(
@@ -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([