run: add shell mode to prompt (#28315)

Press `!` on an empty prompt to enter shell mode and run a command
through session.shell instead of sending a message
This commit is contained in:
Simon Klee
2026-05-20 09:09:12 +02:00
committed by GitHub
parent 11f7e5a1b0
commit 539b118690
12 changed files with 665 additions and 47 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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)

View File

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

View File

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

View File

@@ -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[] = []

View File

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

View File

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