fix(session): run user shells immediately in the background

Run user shell commands concurrently with model execution, retain their output in one shell entry, and admit non-waking completion messages. Share result capture and notification formatting across shell callers.
This commit is contained in:
Kit Langton
2026-08-28 22:46:20 -04:00
committed by GitHub
parent 6b7a1d419c
commit ad9117b107
24 changed files with 1020 additions and 216 deletions
+2 -1
View File
@@ -750,7 +750,8 @@ export function createData(config: CreateDataInput) {
command: event.data.shell.command,
status: event.data.shell.status,
exit: event.data.shell.exit,
metadata: event.metadata,
metadata:
event.data.shell.metadata.background === true ? { ...event.metadata, background: true } : event.metadata,
time: { created: event.created },
})
})
+30
View File
@@ -666,6 +666,36 @@ test("ignores activity snapshots from an older connection", async () => {
}
})
test("projects background user shell metadata from durable shell data", () => {
const setup = activityFixture(() => Response.json({ data: {} }))
try {
setup.emit({
id: "evt_user_shell",
created: 1,
type: "session.shell.started",
durable: { aggregateID: "ses_refresh", seq: 1, version: 1 },
data: {
sessionID: "ses_refresh",
shell: {
id: "sh_user",
status: "running",
command: "pwd",
cwd: "/project",
shell: "/bin/sh",
file: "/project/shell.out",
metadata: { sessionID: "ses_refresh", background: true },
time: { started: 1 },
},
},
})
expect(setup.data.session.message.list("ses_refresh")).toMatchObject([
{ type: "shell", shellID: "sh_user", status: "running", metadata: { background: true } },
])
} finally {
setup.dispose()
}
})
function activityFixture(read: () => Response | Promise<Response>) {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const api = OpenCode.make({
+58 -82
View File
@@ -1,7 +1,7 @@
export * as Session from "./session.js"
export * from "./session/schema.js"
import { Cause, Effect, Layer, Schema, Context, RcMap, Stream, Scope } from "effect"
import { Cause, Effect, Fiber, Layer, Schema, Context, RcMap, Stream, Scope } from "effect"
import { ListAnchor } from "@opencode-ai/schema/session"
import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm"
import { Project } from "./project.js"
@@ -50,8 +50,7 @@ import { Job } from "./job.js"
import { Command } from "./command.js"
import { Shell } from "./shell.js"
import { Global } from "@opencode-ai/util/global"
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
import { KeyedMutex } from "./effect/keyed-mutex.js"
import { ShellResult } from "./shell/result.js"
import { fileURLToPath } from "url"
import { SessionEnvironment } from "./session/environment.js"
import { SessionHistory } from "./session/history.js"
@@ -347,8 +346,6 @@ const layer = Layer.effect(
const jobs = yield* Job.Service
const environments = yield* SessionEnvironment.Service
const scope = yield* Scope.Scope
const activeShells = new Set<SessionSchema.ID>()
const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
const closeTransport = Effect.fn("Session.closeTransport")(function* (session: SessionSchema.Info) {
const location = Location.Ref.make({
directory: session.location.directory,
@@ -680,10 +677,7 @@ const layer = Layer.effect(
// retried payload, metadata, and delivery mode.
if (admitted.type !== "user" || admitted.sessionID !== input.sessionID)
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
if (input.resume !== false) {
if (activeShells.has(admitted.sessionID)) return admitted
yield* execution.wake(admitted.sessionID)
}
if (input.resume !== false) yield* execution.wake(admitted.sessionID)
return admitted
}),
),
@@ -717,60 +711,62 @@ const layer = Layer.effect(
}),
shell: Effect.fn("Session.shell")(function* (input) {
const session = yield* result.get(input.sessionID)
yield* shellLocks.withLock(input.sessionID)(
Effect.gen(function* () {
activeShells.add(input.sessionID)
yield* execution.awaitIdle(input.sessionID)
const started = yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
const shell = yield* Shell.Service
return yield* shell
.create({
command: input.command,
cwd: session.location.directory,
timeout: 0,
metadata: { sessionID: input.sessionID },
})
.pipe(Effect.orDie)
}).pipe(Effect.provide(locations.get(session.location)))
yield* bus.publish(
SessionEvent.Shell.Started,
{
sessionID: input.sessionID,
shell: started,
},
{ id: input.id },
)
const completed = yield* Effect.gen(function* () {
const shell = yield* Shell.Service
const terminal = yield* shell.wait(started.id).pipe(
Effect.map((info) => ({ info, retained: true as const })),
Effect.catchTag("Shell.NotFoundError", () =>
Effect.succeed({ info: synthesizeTerminalShellInfo(started), retained: false as const }),
),
)
const output = terminal.retained
? yield* shell
.output(started.id, { limit: SHELL_MAX_CAPTURE_BYTES })
.pipe(Effect.catchTag("Shell.NotFoundError", () => Effect.succeed(missingShellOutput())))
: missingShellOutput()
return { shell: terminal.info, output }
}).pipe(Effect.provide(locations.get(session.location)))
yield* bus.publish(SessionEvent.Shell.Ended, {
sessionID: input.sessionID,
shell: completed.shell,
output: completed.output,
// The server owns completion recording even if the submitting client disconnects.
const running = yield* Effect.gen(function* () {
// Resolve shell services here without pinning Session events to this Location after a move.
const shell = yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* Shell.Service
}).pipe(Effect.provide(locations.get(session.location)))
const started = yield* shell
.create({
command: input.command,
cwd: session.location.directory,
timeout: 0,
metadata: { sessionID: input.sessionID, background: true },
})
}).pipe(
Effect.ensuring(
Effect.gen(function* () {
activeShells.delete(input.sessionID)
yield* execution.wake(input.sessionID)
}),
),
),
)
.pipe(
Effect.tapError((error) =>
result.synthetic({
sessionID: input.sessionID,
text: `User shell command failed to start:\n${input.command}\n\n${error.message}`,
description: input.command,
metadata: { source: "shell", state: "error" },
resume: false,
}),
),
Effect.orDie,
)
yield* bus.publish(
SessionEvent.Shell.Started,
{
sessionID: input.sessionID,
shell: started,
},
{ id: input.id },
)
const terminal = yield* shell.result(started)
const preview = yield* shell
.output(started.id, { limit: SHELL_MAX_CAPTURE_BYTES })
.pipe(Effect.catchTag("Shell.NotFoundError", () => Effect.succeed(ShellResult.unavailable)))
yield* bus.publish(SessionEvent.Shell.Ended, {
sessionID: input.sessionID,
shell: terminal.info,
output: preview,
})
yield* result
.synthetic({
...ShellResult.userNotification(terminal),
sessionID: input.sessionID,
resume: false,
})
.pipe(
Effect.catchTag("Session.NotFoundError", () => Effect.void),
Effect.orDie,
)
}).pipe(Effect.forkIn(scope, { startImmediately: true }))
yield* Fiber.join(running)
}),
skill: Effect.fn("Session.skill")(function* (input) {
const session = yield* result.get(input.sessionID)
@@ -994,26 +990,6 @@ const layer = Layer.effect(
}),
)
function missingShellOutput() {
const output = "Shell command output is no longer available."
return {
output,
cursor: Buffer.byteLength(output),
size: Buffer.byteLength(output),
truncated: false,
}
}
function synthesizeTerminalShellInfo(started: ShellSchema.Info): ShellSchema.Info {
return {
...started,
// The Shell record was removed before waiters could observe it; publish a terminal
// boundary instead of leaving the Session shell message permanently running.
status: "killed",
time: { ...started.time, completed: Date.now() },
}
}
const preparePrompt = Effect.fn("Session.preparePrompt")(function* (
request: Parameters<Interface["prompt"]>[0],
messageID: SessionMessage.ID,
+3 -1
View File
@@ -164,7 +164,9 @@ const serialize = (message: SessionMessage.Info) => {
if (message.type === "synthetic") return `[Synthetic context]: ${message.text}`
if (message.type === "skill") return `[Skill activated: ${message.name}]\n${message.text}`
if (message.type === "shell")
return `[Shell]: ${message.command}\n${truncateToolOutput(message.output?.output ?? "")}`
return message.metadata?.background === true
? ""
: `[Shell]: ${message.command}\n${truncateToolOutput(message.output?.output ?? "")}`
return ""
}
@@ -9,6 +9,7 @@ import { SessionEvent } from "../event.js"
import { SessionExecution } from "../execution.js"
import { SessionSchema } from "../schema.js"
import { SessionStore } from "../store.js"
import { ShellResult } from "../../shell/result.js"
import { SubagentCompletion } from "../subagent-completion.js"
const CONTINUE_AFTER_SERVER_RESTART =
@@ -116,13 +117,13 @@ export const layer = (options?: Options) =>
id: background.notificationID,
sessionID: recovery.sessionID,
description: recovery.command,
text: `<shell id="${background.id}" state="${state}" command="${recovery.command}">\n${text}\n</shell>`,
metadata: {
source: "shell",
...ShellResult.notification({
jobID: background.id,
shellID: recovery.shellID,
command: recovery.command,
state,
},
text,
}),
...(suspended.has(recovery.sessionID) ? { resume: false } : {}),
})
.pipe(
+2 -1
View File
@@ -169,7 +169,8 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
SessionMessage.Shell.make({
id: SessionMessage.ID.fromEvent(event.id),
type: "shell",
metadata: event.metadata,
metadata:
event.data.shell.metadata.background === true ? { ...event.metadata, background: true } : event.metadata,
shellID: event.data.shell.id,
command: event.data.shell.command,
status: event.data.shell.status,
@@ -260,6 +260,8 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
case "system":
return [Message.system(message.text)]
case "shell":
// Background shell results enter context once, through their completion inbox item.
if (message.metadata?.background === true) return []
return [
Message.make({
id: message.id,
+30 -1
View File
@@ -18,6 +18,9 @@ import type { ShellCreateBefore } from "@opencode-ai/plugin/effect/shell"
import { PluginHooks } from "./plugin/hooks.js"
import { SessionEnvironment } from "./session/environment.js"
import { SessionSchema } from "./session/schema.js"
import { Config } from "./config.js"
import { ToolOutput } from "./tool-output.js"
import { ShellResult } from "./shell/result.js"
export class NotFoundError extends Schema.TaggedError<NotFoundError>()("Shell.NotFoundError", {
id: Shell.ID,
@@ -65,6 +68,8 @@ export interface Interface {
// Resolves once the command reaches a terminal status, returning its final Info. Fails with
// NotFoundError if the command is unknown or is removed before it terminates.
readonly wait: (id: Shell.ID) => Effect.Effect<Shell.Info, NotFoundError>
// A known shell's terminal state and bounded tail. Missing capture remains distinct from its exit status.
readonly result: (started: Shell.Info) => Effect.Effect<ShellResult.Result>
// Replaces the running command's timeout from now; zero clears it.
readonly timeout: (id: Shell.ID, duration: number) => Effect.Effect<Shell.Info, NotFoundError>
readonly output: (id: Shell.ID, input?: Shell.OutputInput) => Effect.Effect<Shell.Output, NotFoundError>
@@ -120,6 +125,7 @@ const layer = () =>
const environment = yield* Environment.Service
const hooks = yield* PluginHooks.Service
const environments = yield* SessionEnvironment.Service
const config = yield* Config.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const commands = new Map<Shell.ID, Active>()
@@ -217,6 +223,28 @@ const layer = () =>
}
})
const result = Effect.fn("Shell.result")(function* (started: Shell.Info) {
const info = yield* wait(started.id).pipe(
Effect.catchTag("Shell.NotFoundError", () =>
Effect.succeed({ ...started, status: "killed" as const, time: { ...started.time, completed: Date.now() } }),
),
)
const capture = yield* Effect.gen(function* () {
const limits = Config.latest(yield* config.entries(), "tool_output")
const maxLines = limits?.max_lines ?? ToolOutput.MAX_LINES
const maxBytes = limits?.max_bytes ?? ToolOutput.MAX_BYTES
const latest = yield* output(info.id, { cursor: Number.MAX_SAFE_INTEGER })
const page = yield* output(info.id, { cursor: Math.max(0, latest.size - maxBytes), limit: maxBytes })
const lines = page.output.split("\n")
if (page.output.endsWith("\n")) lines.pop()
const truncated = latest.size > maxBytes || lines.length > maxLines
const text = lines.length > maxLines ? lines.slice(-maxLines).join("\n") : page.output
const notice = truncated ? `\n\n[output truncated; full output saved to: ${info.file}]` : ""
return { output: `${text || "(no output)"}${notice}`, truncated }
}).pipe(Effect.catchTag("Shell.NotFoundError", () => Effect.succeed(undefined)))
return { info, capture }
})
const create = Effect.fn("Shell.create")(function* <E = never, R = never>(
input: CreateInput,
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
@@ -382,7 +410,7 @@ const layer = () =>
return command.info
})
return Service.of({ create, list, get, wait, timeout, output, remove })
return Service.of({ create, list, get, wait, result, timeout, output, remove })
}),
)
@@ -397,6 +425,7 @@ export const node = makeLocationNode({
Environment.node,
PluginHooks.node,
SessionEnvironment.node,
Config.node,
cleanupNode,
],
})
+74
View File
@@ -0,0 +1,74 @@
export * as ShellResult from "./result.js"
import type { Shell } from "@opencode-ai/schema/shell"
export type Result = {
info: Shell.Info
capture: { output: string; truncated: boolean } | undefined
}
type Output = { output: string; truncated: boolean; exit?: number; timeout?: boolean }
const missing = "Shell command output is no longer available."
export const unavailable: Shell.Output = {
output: missing,
cursor: Buffer.byteLength(missing),
size: Buffer.byteLength(missing),
truncated: false,
}
export function output(result: Result): Output {
return {
output: result.capture?.output ?? unavailable.output,
truncated: result.capture?.truncated ?? false,
...(result.info.exit !== undefined ? { exit: result.info.exit } : {}),
...(result.info.status === "timeout" ? { timeout: true } : {}),
}
}
export function notice(output: Pick<Output, "exit" | "timeout">) {
if (output.timeout) return "Command timed out before completion."
if (output.exit !== undefined) return `Command exited with code ${output.exit}.`
}
export function metadata(output: Output) {
return {
truncated: output.truncated,
...(output.exit !== undefined ? { exit: output.exit } : {}),
...(output.timeout !== undefined ? { timeout: output.timeout } : {}),
}
}
export function notification(input: {
shellID: string
jobID?: string
command: string
state: "completed" | "cancelled" | "error"
text: string
output?: Output
}) {
return {
text: `<shell id="${input.jobID ?? input.shellID}" state="${input.state}" command="${input.command}">\n${input.text}\n</shell>`,
metadata: {
source: "shell",
shellID: input.shellID,
...(input.jobID !== undefined ? { jobID: input.jobID } : {}),
state: input.state,
...(input.output ? metadata(input.output) : {}),
},
}
}
export function userNotification(result: Result) {
const captured = output(result)
const status =
result.info.status === "killed" ? "Command cancelled." : (notice(captured) ?? "Command exited with code unknown.")
const message = notification({
shellID: result.info.id,
command: result.info.command,
state: result.info.status === "killed" ? "cancelled" : "completed",
text: `${captured.output}\n\n${status}`,
output: captured,
})
return { ...message, text: `The following shell command was executed by the user:\n${message.text}` }
}
+18 -63
View File
@@ -13,7 +13,7 @@ import { SessionSchema } from "../../session/schema.js"
import { Shell } from "../../shell.js"
import { ShellParse } from "../../shell/parse.js"
import { ShellSelect } from "../../shell/select.js"
import { ToolOutput } from "../../tool-output.js"
import { ShellResult } from "../../shell/result.js"
export const name = "shell"
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
@@ -71,11 +71,7 @@ const Output = Schema.Struct({
type Output = typeof Output.Type
const resultMessages = (output: Output) => {
const notice = (() => {
if (output.status === "running") return BACKGROUND_INSTRUCTION
if (output.timeout) return "Command timed out before completion."
if (output.exit !== undefined) return `Command exited with code ${output.exit}.`
})()
const notice = output.status === "running" ? BACKGROUND_INSTRUCTION : ShellResult.notice(output)
return [output.output, ...(notice ? [notice] : [])]
}
@@ -85,10 +81,8 @@ const toolResult = (output: Output) => {
content: resultMessages(output).map((text) => ({ type: "text" as const, text })),
metadata: {
status: output.status,
truncated: output.truncated,
...(output.exit !== undefined ? { exit: output.exit } : {}),
...ShellResult.metadata(output),
...(output.shellID !== undefined ? { shellID: output.shellID } : {}),
...(output.timeout !== undefined ? { timeout: output.timeout } : {}),
},
}
}
@@ -132,21 +126,15 @@ export const Plugin = {
yield* runtime.session.synthetic({
...(info.notificationID ? { id: info.notificationID } : {}),
sessionID,
text: `<shell id="${id}" state="${info.status}" command="${command}">\n${text}\n</shell>`,
description: command,
metadata: {
source: "shell",
...ShellResult.notification({
jobID: id,
shellID,
command,
state: info.status,
...(output
? {
truncated: output.truncated,
...(output.exit !== undefined ? { exit: output.exit } : {}),
...(output.timeout !== undefined ? { timeout: output.timeout } : {}),
}
: {}),
},
text,
output,
}),
})
if (info.notificationID) yield* runtime.job.completeBackground(info.notificationID)
},
@@ -229,52 +217,19 @@ export const Plugin = {
)
yield* context.progress({ shellID: info.id })
const captureShell = Effect.fnUntraced(function* () {
const configured = Config.latest(yield* config.entries(), "tool_output")
const maxLines = configured?.max_lines ?? ToolOutput.MAX_LINES
const maxBytes = configured?.max_bytes ?? ToolOutput.MAX_BYTES
const latest = yield* shell.output(info.id, { cursor: Number.MAX_SAFE_INTEGER })
const page = yield* shell.output(info.id, {
cursor: Math.max(0, latest.size - maxBytes),
limit: maxBytes,
})
const lines = page.output.split("\n")
if (page.output.endsWith("\n")) lines.pop()
const truncated = latest.size > maxBytes || lines.length > maxLines
const output = lines.length > maxLines ? lines.slice(-maxLines).join("\n") : page.output
const notice = truncated ? `\n\n[output truncated; full output saved to: ${info.file}]` : ""
const settled = yield* Deferred.make<Output>()
const run = Effect.gen(function* () {
const result = yield* shell.result(info)
if (!result.capture) return yield* new Shell.NotFoundError({ id: info.id })
const output = ShellResult.output(result)
return {
output: `${output || "(no output)"}${notice}`,
truncated,
}
})
const settleShell = Effect.fnUntraced(function* () {
const final = yield* shell.wait(info.id)
const capture = yield* captureShell()
// `exit` is optionalKey in the Output schema; a present-but-undefined key
// fails output encoding, so omit it when the process has no exit code.
if (final.status === "timeout") {
return {
...(final.exit !== undefined ? { exit: final.exit } : {}),
output: `${capture.output}\n\nCommand exceeded timeout of ${finalTimeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
truncated: capture.truncated,
timeout: true,
status: "completed" as const,
}
}
return {
...(final.exit !== undefined ? { exit: final.exit } : {}),
output: capture.output,
truncated: capture.truncated,
...output,
output: output.timeout
? `${output.output}\n\nCommand exceeded timeout of ${finalTimeout} ms. Retry with a larger timeout if the command is expected to take longer.`
: output.output,
status: "completed" as const,
}
})
const settled = yield* Deferred.make<Output>()
const run = settleShell().pipe(
}).pipe(
Effect.tap((output) => Deferred.succeed(settled, output)),
Effect.map((output) => resultMessages(output).join("\n\n")),
Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)),
+22 -1
View File
@@ -27,6 +27,7 @@ import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Money } from "@opencode-ai/schema/money"
import { Skill } from "@opencode-ai/schema/skill"
import { Shell } from "@opencode-ai/schema/shell"
import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { asc, eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
@@ -260,7 +261,25 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
session,
resolveModel: () => Effect.succeed(resolved),
prepare: modelRequests.prepare,
messages: [userMessage],
messages: [
userMessage,
SessionMessage.Shell.make({
id: SessionMessage.ID.create(),
type: "shell",
shellID: Shell.ID.make("sh_background"),
status: "exited",
command: "pwd",
metadata: { background: true },
output: { output: "display-only-output", cursor: 19, size: 19, truncated: false },
time: { created: DateTime.makeUnsafe(0), completed: DateTime.makeUnsafe(1) },
}),
SessionMessage.Synthetic.make({
id: SessionMessage.ID.create(),
type: "synthetic",
text: "User shell pwd completed: /project",
time: { created: DateTime.makeUnsafe(2) },
}),
],
inputID: SessionMessage.ID.make("msg_manual_compaction"),
}),
).toEqual({ status: "completed" })
@@ -280,6 +299,8 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
expect(requests[0]?.generation).toBeUndefined()
expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.")
expect(JSON.stringify(requests[0]?.messages)).toContain("Use Effect services and generators.")
expect(JSON.stringify(requests[0]?.messages)).toContain("User shell pwd completed: /project")
expect(JSON.stringify(requests[0]?.messages)).not.toContain("display-only-output")
expect(yield* store.context(sessionID)).toMatchObject([
{ type: "compaction", reason: "manual", summary: "manual summary", recent: "" },
])
+9 -3
View File
@@ -499,15 +499,21 @@ describe("SessionRestart background recovery", () => {
yield* Context.get(context, SessionExecution.Service).awaitIdle(sessionID)
expect(drained).toEqual([sessionID])
expect(yield* SessionInbox.list(database.db, sessionID)).toMatchObject([
const inbox = yield* SessionInbox.list(database.db, sessionID)
expect(inbox).toMatchObject([
{
type: "synthetic",
payload: {
text: expect.stringContaining("(no output)\n\nCommand exited with code 7."),
metadata: { source: "shell", shellID: "sh_completed", state: "completed" },
text: '<shell id="call-completed-shell" state="completed" command="exit 7">\n(no output)\n\nCommand exited with code 7.\n</shell>',
},
},
])
expect(inbox[0]).toHaveProperty("payload.metadata", {
source: "shell",
jobID: "call-completed-shell",
shellID: "sh_completed",
state: "completed",
})
expect(yield* restarted.pendingBackground).toEqual([])
}),
)
+21 -12
View File
@@ -330,18 +330,26 @@ describe("SessionProjector", () => {
text: "synthetic context",
metadata: { source: "projector-test" },
})
yield* bus.publish(SessionEvent.Shell.Started, {
sessionID,
shell: Shell.Info.make({
id: Shell.ID.make("sh_projector"),
status: "running",
command: "pwd",
cwd: "/project",
shell: "/bin/sh",
file: "/tmp/sh_projector.out",
metadata: {},
time: { started: 0 },
}),
// Serialized events have no transient envelope metadata to supply the background marker.
yield* bus.replay({
id: Event.ID.create(),
created: 0,
aggregateID: sessionID,
seq: 3,
type: Bus.versionedType(SessionEvent.Shell.Started.type, 1),
data: {
sessionID,
shell: Shell.Info.make({
id: Shell.ID.make("sh_projector"),
status: "running",
command: "pwd",
cwd: "/project",
shell: "/bin/sh",
file: "/tmp/sh_projector.out",
metadata: { background: true },
time: { started: 0 },
}),
},
})
yield* bus.publish(SessionEvent.Shell.Ended, {
sessionID,
@@ -421,6 +429,7 @@ describe("SessionProjector", () => {
command: "pwd",
status: "exited",
exit: 0,
metadata: { background: true },
output: { output: "/project", truncated: false },
time: { completed: DateTime.makeUnsafe(0) },
})
@@ -20,6 +20,38 @@ const model = Model.Ref.make({ id: Model.ID.make("model"), providerID: Provider.
const build = Agent.defaultID
describe("toLLMMessages", () => {
test("background user shells enter model context only through their completion notification", () => {
const shell = SessionMessage.Shell.make({
id: id("background-shell"),
type: "shell",
shellID: Shell.ID.make("sh_background"),
status: "running",
command: "pwd",
metadata: { background: true },
time: { created },
})
const notification = SessionMessage.Synthetic.make({
id: id("shell-completion"),
type: "synthetic",
text: "User shell pwd completed: /project",
metadata: { source: "shell", shellID: shell.shellID, state: "completed" },
time: { created },
})
expect(toLLMMessages([shell], model)).toEqual([])
const completed = SessionMessage.Shell.make({
...shell,
status: "exited",
exit: 0,
output: { output: "/project", cursor: 8, size: 8, truncated: false },
time: { created, completed: created },
})
expect(toLLMMessages([completed], model)).toEqual([])
expect(toLLMMessages([completed, notification], model)).toEqual([
Message.make({ id: notification.id, role: "user", content: notification.text }),
])
})
test("omits empty assistant turns", () => {
const assistant = (value: string, content: SessionMessage.Assistant["content"]) =>
SessionMessage.Assistant.make({
+375
View File
@@ -0,0 +1,375 @@
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Cause, Context, Deferred, Effect, Exit, Fiber, Layer, Option, Schedule, Stream } from "effect"
import { Bus } from "@opencode-ai/core/bus"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator"
import { Shell } from "@opencode-ai/core/shell"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { location } from "./fixture/location"
import { tmpdirScoped } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
class ExecutionControl extends Context.Service<
ExecutionControl,
{
started: Deferred.Deferred<void>
release: Deferred.Deferred<void>
wakes: Session.ID[]
}
>()("test/SessionShell/ExecutionControl") {}
const controlLayer = Layer.effect(
ExecutionControl,
Effect.gen(function* () {
return { started: yield* Deferred.make<void>(), release: yield* Deferred.make<void>(), wakes: [] }
}),
)
const executionLayer = Layer.effect(
SessionExecution.Service,
Effect.gen(function* () {
const control = yield* ExecutionControl
const coordinator = yield* SessionRunCoordinator.make<Session.ID, never>({
drain: () => Deferred.succeed(control.started, undefined).pipe(Effect.andThen(Deferred.await(control.release))),
})
return SessionExecution.Service.of({
active: coordinator.active,
resume: coordinator.run,
interrupt: (sessionID) => coordinator.interrupt(sessionID),
awaitIdle: coordinator.awaitIdle,
wake: (sessionID) =>
Effect.sync(() => control.wakes.push(sessionID)).pipe(Effect.andThen(coordinator.wake(sessionID))),
})
}),
)
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Bus.node, Session.node, SessionExecution.node, LocationServiceMap.node]), [
[Bus.node, Bus.configured({ persist: true })],
[SessionExecution.node, executionLayer],
]).pipe(Layer.provideMerge(controlLayer)),
)
const setup = Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
const session = yield* Session.Service
const created = yield* session.create({ location: Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }) })
const locations = yield* LocationServiceMap.Service
const shell = yield* Shell.Service.pipe(Effect.provide(locations.get(created.location)))
const control = yield* ExecutionControl
const execution = yield* SessionExecution.Service
return { tmp, session, created, shell, control, execution }
})
const log = (session: Session.Interface, sessionID: Session.ID, follow = false) =>
session.log({ sessionID, follow }).pipe(Stream.filter((event) => !Bus.isSynced(event)))
// File gates prove the child process is running without relying on elapsed-time assertions.
const launch = Effect.fn(function* (fixture: Effect.Success<typeof setup>, name: string) {
const release = Effect.promise(() => Bun.write(path.join(fixture.tmp.path, `${name}.release`), "")).pipe(
Effect.asVoid,
)
yield* Effect.addFinalizer(() => release)
const command =
process.platform === "win32"
? `Write-Output '${name} started'; New-Item '${name}.started' -ItemType File | Out-Null; while (!(Test-Path '${name}.release') -and (Test-Path $PWD)) { Start-Sleep -Milliseconds 10 }; Write-Output '${name} finished'`
: `printf '${name} started\\n'; touch ${name}.started; while [ ! -f ${name}.release ] && [ -d "$PWD" ]; do sleep 0.01; done; printf '${name} finished\\n'`
const caller = yield* fixture.session
.shell({ sessionID: fixture.created.id, command })
.pipe(Effect.scoped, Effect.forkScoped)
yield* Effect.promise(() => Bun.file(path.join(fixture.tmp.path, `${name}.started`)).exists()).pipe(
Effect.repeat({ until: (exists) => exists, schedule: Schedule.spaced("10 millis") }),
Effect.timeout("5 seconds"),
)
const event = yield* log(fixture.session, fixture.created.id, true).pipe(
Stream.filter((event): event is SessionEvent.Shell.Started => event.type === "session.shell.started"),
Stream.filter((event) => event.data.shell.command === command),
Stream.runHead,
Effect.map(Option.getOrThrow),
Effect.timeout("5 seconds"),
)
return { caller, shellID: event.data.shell.id, release }
})
describe("Session.shell", () => {
it.live("runs shells concurrently with an active model and waits for each shell's own completion", () =>
Effect.gen(function* () {
const fixture = yield* setup
const model = yield* fixture.execution.resume(fixture.created.id).pipe(Effect.forkScoped)
yield* Deferred.await(fixture.control.started).pipe(Effect.timeout("5 seconds"))
const first = yield* launch(fixture, "first")
const second = yield* launch(fixture, "second")
expect(yield* fixture.execution.active).toContain(fixture.created.id)
expect(first.caller.pollUnsafe()).toBeUndefined()
expect(second.caller.pollUnsafe()).toBeUndefined()
expect(yield* fixture.session.messages({ sessionID: fixture.created.id, order: "asc" })).toMatchObject([
{ type: "shell", shellID: first.shellID, status: "running", metadata: { background: true } },
{ type: "shell", shellID: second.shellID, status: "running", metadata: { background: true } },
])
yield* second.release
yield* Fiber.join(second.caller).pipe(Effect.timeout("5 seconds"))
expect(first.caller.pollUnsafe()).toBeUndefined()
expect(yield* fixture.session.inbox(fixture.created.id)).toMatchObject([
{
type: "synthetic",
payload: {
metadata: { source: "shell", shellID: second.shellID, state: "completed" },
text: expect.stringContaining("second finished"),
},
},
])
yield* first.release
yield* Fiber.join(first.caller).pipe(Effect.timeout("5 seconds"))
expect(
(yield* fixture.session.inbox(fixture.created.id))
.filter((item) => item.type === "synthetic")
.map((item) => item.payload.metadata?.shellID),
).toEqual([second.shellID, first.shellID])
expect(yield* fixture.execution.active).toContain(fixture.created.id)
expect(fixture.control.wakes).toEqual([])
yield* Deferred.succeed(fixture.control.release, undefined)
yield* Fiber.join(model).pipe(Effect.timeout("5 seconds"))
}),
)
it.live("routes shell completion to the moved Session while retaining output from its original Location", () =>
Effect.gen(function* () {
const fixture = yield* setup
const destination = Location.Ref.make({ directory: AbsolutePath.make((yield* tmpdirScoped()).path) })
const command = yield* launch(fixture, "moved")
const bus = yield* Bus.Service
const Done = Bus.ephemeral({ type: "test.shell.move.done", schema: {} })
const readers = yield* Effect.forEach([fixture.created.location, destination], (ref) =>
bus.subscribe([SessionEvent.Shell.Ended, SessionEvent.InboxEnqueued, Done]).pipe(
Stream.takeUntil((event) => event.type === Done.type),
Stream.runCollect,
Effect.provideService(Location.Service, location(ref)),
Effect.forkScoped({ startImmediately: true }),
),
)
// This fixture's runner does not drain move requests; project the actual move event instead.
yield* bus.publish(SessionEvent.Moved, {
sessionID: fixture.created.id,
location: destination,
projectID: fixture.created.projectID,
})
expect((yield* fixture.session.get(fixture.created.id)).location).toEqual(destination)
yield* command.release
yield* Fiber.join(command.caller).pipe(Effect.timeout("5 seconds"))
yield* bus.publish(Done, {})
const events = yield* Effect.forEach(readers, (reader) => Fiber.join(reader).pipe(Effect.timeout("5 seconds")))
expect(events.map((items) => items.map((event) => event.type))).toEqual([
[Done.type],
["session.shell.ended", "session.inbox.enqueued", Done.type],
])
expect(events[1][0]).toMatchObject({
data: { output: { output: expect.stringContaining("moved finished") } },
})
const inbox = yield* fixture.session.inbox(fixture.created.id)
expect(inbox).toHaveLength(1)
expect(inbox[0]).toMatchObject({
type: "synthetic",
payload: {
text: expect.stringContaining("moved finished"),
metadata: { shellID: command.shellID, state: "completed" },
},
})
}),
)
it.live("does not suppress a normal prompt wake while a shell is running or wake again when it completes", () =>
Effect.gen(function* () {
const fixture = yield* setup
const command = yield* launch(fixture, "prompt")
const prompt = yield* fixture.session.prompt({ sessionID: fixture.created.id, text: "Continue while this runs" })
yield* Deferred.await(fixture.control.started).pipe(Effect.timeout("5 seconds"))
expect(fixture.control.wakes).toEqual([fixture.created.id])
expect(command.caller.pollUnsafe()).toBeUndefined()
yield* Deferred.succeed(fixture.control.release, undefined)
yield* fixture.execution.awaitIdle(fixture.created.id)
yield* command.release
yield* Fiber.join(command.caller).pipe(Effect.timeout("5 seconds"))
expect(fixture.control.wakes).toEqual([fixture.created.id])
expect(yield* fixture.execution.active).toEqual(new Set())
expect(yield* fixture.session.inbox(fixture.created.id)).toMatchObject([
{ id: prompt.id, type: "user" },
{ type: "synthetic", payload: { metadata: { source: "shell" } } },
])
}),
)
for (const exit of [0, 7]) {
it.live(`records output and admits one completion without waking the model after exit ${exit}`, () =>
Effect.gen(function* () {
const fixture = yield* setup
const command =
process.platform === "win32"
? `Write-Output 'user output'; [Console]::Error.WriteLine('user error'); exit ${exit}`
: `printf 'user output\\n'; printf 'user error\\n' >&2; exit ${exit}`
yield* fixture.session.shell({ sessionID: fixture.created.id, command })
const events = yield* log(fixture.session, fixture.created.id).pipe(Stream.runCollect)
expect(events.map((event) => event.type)).toEqual([
"session.created",
"session.shell.started",
"session.shell.ended",
"session.inbox.enqueued",
])
expect(events[1]).toMatchObject({ data: { shell: { metadata: { background: true } } } })
const messages = yield* fixture.session.messages({ sessionID: fixture.created.id })
expect(messages).toHaveLength(1)
expect(messages[0]).toMatchObject({
type: "shell",
command,
status: "exited",
exit,
metadata: { background: true },
})
const message = messages[0]
if (message?.type !== "shell") return yield* Effect.die("Missing shell projection")
expect(message.time.completed).toBeDefined()
expect(message.output?.output).toContain("user output")
expect(message.output?.output).toContain("user error")
expect(message.output?.truncated).toBe(false)
const inbox = yield* fixture.session.inbox(fixture.created.id)
expect(inbox).toHaveLength(1)
expect(inbox[0]).toMatchObject({
type: "synthetic",
delivery: "steer",
})
const completion = inbox[0]
if (completion?.type !== "synthetic") return yield* Effect.die("Missing shell completion")
expect(completion.payload.metadata).toEqual({
source: "shell",
shellID: message.shellID,
state: "completed",
exit,
truncated: false,
})
expect(completion.payload.description).toBeUndefined()
expect(completion.payload.text).toContain(command)
expect(completion.payload.text).toContain("user output")
expect(completion.payload.text).toContain("user error")
expect(completion.payload.text).toContain(`exited with code ${exit}`)
expect(fixture.control.wakes).toEqual([])
}),
)
}
for (const outcome of [
{
status: "killed",
state: "cancelled",
text: "Command cancelled",
output: "Shell command output is no longer available.",
},
{ status: "timeout", state: "completed", text: "Command timed out", output: "timeout started" },
]) {
it.live(`records a ${outcome.status} shell and admits its completion without waking the model`, () =>
Effect.gen(function* () {
const fixture = yield* setup
const command = yield* launch(fixture, outcome.status)
yield* outcome.status === "killed"
? fixture.shell.remove(command.shellID)
: fixture.shell.timeout(command.shellID, 1)
yield* Fiber.join(command.caller).pipe(Effect.timeout("5 seconds"))
expect(yield* fixture.session.messages({ sessionID: fixture.created.id })).toMatchObject([
{
type: "shell",
shellID: command.shellID,
status: outcome.status,
time: { completed: expect.anything() },
output: { output: expect.stringContaining(outcome.output) },
},
])
const inbox = yield* fixture.session.inbox(fixture.created.id)
expect(inbox).toHaveLength(1)
expect(inbox).toMatchObject([
{
type: "synthetic",
payload: {
text: expect.stringContaining(outcome.text),
metadata: { source: "shell", shellID: command.shellID, state: outcome.state },
},
},
])
expect(inbox[0]).not.toHaveProperty("payload.metadata.exit")
expect(fixture.control.wakes).toEqual([])
}),
)
}
it.live("admits a spawn failure without waking the model before failing the caller", () =>
Effect.gen(function* () {
const fixture = yield* setup
// Keep the Location initialized, but invalidate the real child's working directory.
yield* Effect.promise(() => fs.rm(fixture.tmp.path, { recursive: true, force: true }))
const command = "echo should-not-start"
const exit = yield* fixture.session.shell({ sessionID: fixture.created.id, command }).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
expect(yield* fixture.session.messages({ sessionID: fixture.created.id })).toEqual([])
const inbox = yield* fixture.session.inbox(fixture.created.id)
expect(inbox).toHaveLength(1)
expect(inbox[0]).toMatchObject({
type: "synthetic",
payload: { text: expect.stringContaining(command), metadata: { source: "shell", state: "error" } },
})
expect(fixture.control.wakes).toEqual([])
}),
)
it.live("keeps the shell and completion alive when the waiting caller is interrupted", () =>
Effect.gen(function* () {
const fixture = yield* setup
const command = yield* launch(fixture, "detached")
yield* Fiber.interrupt(command.caller).pipe(Effect.timeout("5 seconds"))
const exit = yield* Fiber.await(command.caller)
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
expect(yield* fixture.shell.get(command.shellID)).toMatchObject({ status: "running" })
expect(yield* fixture.session.inbox(fixture.created.id)).toEqual([])
yield* command.release
yield* log(fixture.session, fixture.created.id, true).pipe(
Stream.filter((event) => event.type === "session.inbox.enqueued"),
Stream.runHead,
Effect.timeout("5 seconds"),
)
expect(yield* fixture.session.messages({ sessionID: fixture.created.id })).toMatchObject([
{
type: "shell",
shellID: command.shellID,
status: "exited",
exit: 0,
output: { output: expect.stringContaining("detached finished") },
},
])
expect(yield* fixture.session.inbox(fixture.created.id)).toMatchObject([
{
type: "synthetic",
payload: {
text: expect.stringContaining("detached finished"),
metadata: { shellID: command.shellID, state: "completed" },
},
},
])
expect(fixture.control.wakes).toEqual([])
}),
)
})
+6
View File
@@ -1291,6 +1291,7 @@ describe("ShellTool", () => {
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.metadata).toMatchObject({ timeout: true, truncated: false })
expect(settled.metadata).not.toHaveProperty("exit")
expect(settled.content?.[0]).toMatchObject(Expected.text(expect.stringContaining("before timeout")))
expect(settled.content?.[1]).toMatchObject(Expected.text(expect.stringContaining("Command timed out")))
}),
@@ -1486,6 +1487,7 @@ describe("ShellTool", () => {
Effect.gen(function* () {
const info = yield* shell.create({ command: idleCommand, timeout: 0 })
yield* shell.remove(info.id)
expect((yield* shell.result(info)).capture).toBeUndefined()
yield* Effect.sleep(Duration.millis(10))
}),
)
@@ -1493,6 +1495,10 @@ describe("ShellTool", () => {
const info = yield* shell.create({ command: helloCommand, timeout: 0 })
const settled = yield* shell.wait(info.id).pipe(Effect.timeoutOption(Duration.seconds(2)))
expect(settled._tag).toBe("Some")
expect(yield* shell.result(info)).toMatchObject({
info: { status: "exited", exit: 0 },
capture: { output: expect.stringContaining("hello"), truncated: false },
})
}),
),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
@@ -0,0 +1,39 @@
import { expect, story } from "../../storybook/playwright/story"
story("streams user shell output and retains the saved completion", async ({ mount }) => {
const root = await mount("current-session-terminal-work--live-user-command")
const shell = root.locator('[data-component="session-shell-message"]')
const output = shell.locator('[data-slot="bash-result"]')
await expect(shell.locator('[data-slot="bash-command"]')).toHaveText("printf ready")
await expect(output).toHaveText("ready\n")
await root.getByRole("button", { name: "Update output", exact: true }).click()
await expect(output).toHaveText("ready\nnext line\n")
await root.getByRole("button", { name: "Complete command", exact: true }).click()
await expect(output).toHaveText("ready\nnext line\nfinished\n")
await expect(shell.locator('[data-kind="tool-error-card"]')).toHaveCount(0)
})
for (const [outcome, error] of [
["nonzero", "Command exited with code 1"],
["timeout", "Command timed out"],
["killed", "Command cancelled"],
]) {
story(`shows a direct shell ${outcome} outcome even without output`, async ({ mount }) => {
const root = await mount("current-session-terminal-work--live-user-command", { args: { outcome, output: false } })
const shell = root.locator('[data-component="session-shell-message"]')
await root.getByRole("button", { name: "Complete command", exact: true }).click()
await expect(shell.locator('[data-kind="tool-error-card"]')).toContainText(error)
await expect(shell.locator('[data-slot="bash-command"]')).toHaveText("printf ready")
await expect(shell.locator('[data-slot="bash-result"]')).toHaveCount(0)
})
}
for (const [id, output] of [
["running-a-user-command", "Starting Storybook manager...\nBuilding preview..."],
["user-command-completed", " M packages/session-ui/src/timeline/session-timeline.tsx"],
]) {
story(`keeps captured output without a live reader: ${id}`, async ({ mount }) => {
const root = await mount(`current-session-terminal-work--${id}`)
await expect(root.locator('[data-component="session-shell-message"] [data-slot="bash-result"]')).toHaveText(output)
})
}
@@ -53,6 +53,13 @@ describe("current session timeline rows", () => {
output: { output: "/repo", cursor: 5, size: 5, truncated: false },
time: { created: 1, completed: 2 },
},
{
id: "msg_shell_completion",
type: "synthetic",
text: "User shell pwd completed: /repo",
metadata: { source: "shell", shellID: "shell_1", state: "completed" },
time: { created: 3 },
},
] satisfies SessionMessageInfo[]
const result = Timeline.constructSessionMessageRows(source, true, { type: "idle" })
@@ -1,6 +1,8 @@
import type { SessionMessageAssistant } from "@opencode-ai/client/promise"
import { createMemo } from "solid-js"
import type { SessionMessageAssistant, SessionMessageShell } from "@opencode-ai/client/promise"
import { createMemo, createSignal } from "solid-js"
import { createStore } from "solid-js/store"
import { DataProvider } from "../context/data"
import { SessionShellMessage } from "../tools/tool-renderer"
import { CurrentSessionProviders, CurrentSessionTimelineStory } from "../storybook/current-session-story"
import {
executeCodeDocument,
@@ -66,6 +68,68 @@ export const UserCommandCompleted = {
),
}
export const LiveUserCommand = {
args: { outcome: "exited", output: true },
argTypes: { outcome: { control: "select", options: ["exited", "nonzero", "timeout", "killed"] } },
render: (args: { outcome: "exited" | "nonzero" | "timeout" | "killed"; output: boolean }) => {
const [message, setMessage] = createSignal<SessionMessageShell>({
id: "msg_shell_live",
type: "shell",
shellID: "shell_live",
command: "printf ready",
status: "running",
time: { created: 1 },
})
let output = args.output ? "ready\n" : ""
return (
<section class="mx-auto flex w-full max-w-[720px] flex-col gap-4 p-6">
<button type="button" onClick={() => (output += "next line\n")}>
Update output
</button>
<button
type="button"
onClick={() => {
if (args.output) output += "finished\n"
setMessage((value) => ({
...value,
status: args.outcome === "nonzero" ? "exited" : args.outcome,
exit: args.outcome === "nonzero" ? 1 : args.outcome === "exited" ? 0 : undefined,
output: { output, cursor: output.length, size: output.length, truncated: false },
time: { created: 1, completed: 2 },
}))
}}
>
Complete command
</button>
<DataProvider
directory="/workspace"
data={{ session: [], session_status: {}, session_diff: {} }}
shellOutput={async (input) => {
if (message().status !== "running") throw new Error("Shell output unavailable")
if (input.id !== "shell_live" || input.location?.directory !== "/workspace") {
throw new Error("Unexpected shell output request")
}
return {
location: {
directory: "/workspace",
project: { id: "project_shell", directory: "/workspace", canonical: "/workspace" },
},
data: {
output: output.slice(input.cursor ?? 0),
cursor: output.length,
size: output.length,
truncated: false,
},
}
}}
>
<SessionShellMessage message={message()} defaultOpen />
</DataProvider>
</section>
)
},
}
const CollapsedShell = {
render: () => (
<CurrentSessionTimelineStory
@@ -1475,7 +1475,14 @@ ToolRegistry.register({
(typeof props.metadata.shellID === "string" && data.shellRunning?.(props.metadata.shellID) === true)
const sawStreaming = streaming()
const [streamed, setStreamed] = createSignal("")
// Direct-user terminal snapshots are authoritative; agent results can describe background shells.
const saved = createMemo(() =>
props.metadata.status === "exited" || props.metadata.status === "timeout" || props.metadata.status === "killed"
? props.output
: undefined,
)
createEffect(() => {
if (saved() !== undefined) return
const id = props.metadata.shellID
const shellOutput = data.shellOutput
if (typeof id !== "string" || !shellOutput) return
@@ -1511,7 +1518,7 @@ ToolRegistry.register({
return ""
}
const output = createMemo(() =>
stripAnsi((typeof props.metadata.shellID === "string" && streamed()) || props.output || "").replace(
stripAnsi(saved() ?? ((typeof props.metadata.shellID === "string" && streamed()) || props.output || "")).replace(
/\r\n?/g,
"\n",
),
@@ -1560,7 +1567,15 @@ export function SessionShellMessage(props: {
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const i18n = useI18n()
const render = ToolRegistry.render("shell") ?? GenericTool
const error = createMemo(() => {
const message = props.message
if (message.status === "timeout") return i18n.t("ui.tool.shell.timeout")
if (message.status === "killed") return i18n.t("ui.tool.shell.cancelled")
if (message.status !== "exited" || message.exit === undefined || message.exit === 0) return
return i18n.t("ui.tool.shell.exit", { code: message.exit })
})
return (
<div data-component="session-shell-message" data-timeline-part-id={props.message.id}>
<Dynamic
@@ -1568,6 +1583,7 @@ export function SessionShellMessage(props: {
tool="shell"
input={{ command: props.message.command }}
metadata={{
shellID: props.message.shellID,
status: props.message.status,
exit: props.message.exit,
truncated: props.message.output?.truncated,
@@ -1578,6 +1594,9 @@ export function SessionShellMessage(props: {
open={props.open}
onOpenChange={props.onOpenChange}
/>
<Show when={error()}>
{(error) => <ToolErrorCard tool="shell" error={error()} subtitle={props.message.command} />}
</Show>
</div>
)
}
+60 -40
View File
@@ -2329,26 +2329,21 @@ function RevertMessage(props: {
}
function ShellMessage(props: { message: Extract<SessionMessageInfo, { type: "shell" }> }) {
const theme = useTheme("elevated")
const output = createMemo(() => stripAnsi(props.message.output?.output.trim() ?? ""))
const error = createMemo(() => {
if (props.message.status === "killed") return "Command cancelled"
if (props.message.status === "timeout") return "Command timed out"
if (props.message.exit !== undefined && props.message.exit !== 0)
return `Command exited with code ${props.message.exit}`
})
return (
<box
width="100%"
border={["left"]}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
gap={1}
backgroundColor={theme.background.default}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.background.default}
>
<text fg={theme.text.default}>$ {props.message.command}</text>
<Show when={output()}>
<text fg={theme.text.subdued}>{output()}</text>
</Show>
</box>
<ShellDisplay
shellID={props.message.shellID}
command={props.message.command}
status={props.message.status === "running" ? "running" : "completed"}
output={props.message.output?.output}
error={error()}
/>
)
}
@@ -3091,6 +3086,7 @@ type BlockToolProps = {
onClick?: () => void
part?: SessionMessageAssistantTool
spinner?: boolean
error?: string
}
function BlockTool(props: BlockToolProps) {
@@ -3107,7 +3103,9 @@ function BlockToolContent(props: BlockToolProps & { borderColor: RGBA }) {
const ctx = use()
const renderer = useRenderer()
const [hover, setHover] = createSignal(false)
const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error.message : undefined))
const error = createMemo(
() => props.error ?? (props.part?.state.status === "error" ? props.part.state.error.message : undefined),
)
const permission = useToolPermission(() => props.part)
return (
<box
@@ -3178,22 +3176,44 @@ function BlockToolContent(props: BlockToolProps & { borderColor: RGBA }) {
const SHELL_DISPLAY_LIMIT = 1024 * 1024
function Shell(props: ToolProps) {
return (
<ShellDisplay
part={props.part}
shellID={stringValue(props.metadata.shellID)}
command={stringValue(props.input.command)}
workdir={stringValue(props.input.workdir)}
status={props.part.state.status}
background={Boolean(stringValue(props.metadata.shellID)) && props.part.state.status !== "running"}
output={stringValue(props.metadata.shellID) ? undefined : props.output}
/>
)
}
function ShellDisplay(props: {
part?: SessionMessageAssistantTool
shellID?: string
command?: string
workdir?: string
status: SessionMessageAssistantTool["state"]["status"]
background?: boolean
output?: string
error?: string
}) {
const theme = useTheme()
const ctx = use()
const client = useClient()
const data = useData()
const pathFormatter = usePathFormatter()
// A Session can move while its shell is still running in the original Location.
const location = data.shell.get(props.shellID ?? "")?.location ?? data.session.get(ctx.sessionID)?.location
const permission = useToolPermission(() => props.part)
const color = createMemo(() => (permission() ? theme.text.feedback.warning.default : theme.text.default))
const shellID = createMemo(() => stringValue(props.metadata.shellID))
const background = createMemo(() => Boolean(shellID()) && props.part.state.status !== "running")
const backgroundRunning = createMemo(() => {
const id = shellID()
const id = props.shellID
return Boolean(id && data.shell.get(id))
})
const isRunning = createMemo(() => props.part.state.status === "running" || backgroundRunning())
const command = createMemo(() => stringValue(props.input.command))
const workdir = createMemo(() => pathFormatter.format(stringValue(props.input.workdir)))
const isRunning = createMemo(() => props.status === "running" || backgroundRunning())
const workdir = createMemo(() => pathFormatter.format(props.workdir))
const [expanded, setExpanded] = createSignal(false)
const [backgroundOutput, setBackgroundOutput] = createSignal("")
const [outputTruncated, setOutputTruncated] = createSignal(false)
@@ -3202,14 +3222,14 @@ function Shell(props: ToolProps) {
let cursor = 0
let wasRunning = false
const loadBackgroundOutput = async (drain = false) => {
const id = shellID()
if (props.status === "completed" && props.output !== undefined) return
const id = props.shellID
if (!id) return
if (loading) {
if (drain) drainRequested = true
return
}
loading = true
const location = data.session.get(ctx.sessionID)?.location
do {
const response = await client.api.shell
.output({
@@ -3250,25 +3270,25 @@ function Shell(props: ToolProps) {
return
}
wasRunning = true
if (background() && !expanded()) return
if (props.background && !expanded()) return
void loadBackgroundOutput()
const interval = setInterval(() => void loadBackgroundOutput(), 1_000)
onCleanup(() => clearInterval(interval))
})
const output = createMemo(() => {
if (props.part.state.status === "streaming") return ""
if (shellID()) {
if (background() && !expanded()) return ""
const text = backgroundOutput().trim()
if (props.status === "streaming") return ""
if (props.shellID) {
if (props.background && !expanded()) return ""
if (props.status === "completed" && props.output !== undefined) return stripAnsi(props.output.trim())
const text = stripAnsi((backgroundOutput() || props.output || "").trim())
return outputTruncated() ? `[earlier output omitted]\n${text}` : text
}
const content = toolDisplayContent(props.part.state)[0]
return stripAnsi(content?.type === "text" ? content.text.trim() : "")
return stripAnsi(props.output?.trim() ?? "")
})
const maxLines = 10
const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6))
const prefix = createMemo(() => (workdir() && workdir() !== "." ? `cd ${workdir()} && ` : ""))
const input = createMemo(() => (command() ? `${isRunning() ? "" : "$ "}${prefix()}${command()}` : ""))
const input = createMemo(() => (props.command ? `${isRunning() ? "" : "$ "}${prefix()}${props.command}` : ""))
const content = createMemo(() => [input(), output()].filter(Boolean).join("\n\n"))
const collapsed = createMemo(() => collapseToolOutput(content(), maxLines, maxChars()))
const limited = createMemo(() => {
@@ -3277,7 +3297,7 @@ function Shell(props: ToolProps) {
})
const limitedInput = createMemo(() => limited().slice(0, input().length))
const limitedOutput = createMemo(() => limited().slice(Math.min(limited().length, input().length + 2)))
const expandable = createMemo(() => Boolean(shellID()) || collapsed().overflow)
const expandable = createMemo(() => Boolean(props.shellID) || collapsed().overflow)
const toggle = () => {
const next = !expanded()
setExpanded(next)
@@ -3285,12 +3305,12 @@ function Shell(props: ToolProps) {
}
return (
<BlockTool part={props.part} onClick={expandable() ? toggle : undefined}>
<BlockTool part={props.part} error={props.error} onClick={expandable() ? toggle : undefined}>
<box gap={1}>
<Show
when={command()}
when={props.command}
fallback={
isRunning() || props.part.state.status === "streaming" ? (
isRunning() || props.status === "streaming" ? (
<Spinner color={color()}>Writing command…</Spinner>
) : (
<text fg={theme.text.subdued}>Writing command…</text>
@@ -3304,7 +3324,7 @@ function Shell(props: ToolProps) {
<text fg={theme.text.subdued}>{limitedOutput()}</text>
</Show>
</Show>
<Show when={background()}>
<Show when={props.background}>
<StatusBadge>Background</StatusBadge>
</Show>
</box>
+122
View File
@@ -867,6 +867,128 @@ test("shows jump to latest after scrolling one line above the final message", as
}
})
test("completed user shell output replaces a partial live read when the final read fails", async () => {
await using state = await tmpdir()
const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true })
setup.renderer.start()
const events = createEventStream()
const session = {
id: "ses_shell_output",
title: "Shell output fixture",
projectID: "proj_test",
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}
const shell = {
id: "sh_output",
command: "echo shell-output-fixture",
status: "running" as const,
cwd: directory,
shell: "/bin/sh",
file: `${directory}/shell.out`,
metadata: { sessionID: session.id, background: true },
time: { started: 1 },
}
const partial = "first live output\n"
const completed = `${partial}last persisted output\n`
let finished = false
let failedReads = 0
const calls = createFetch((url) => {
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
if (url.pathname === `/api/session/${session.id}/message`)
return json({
data: [
{
id: "msg_shell_output",
type: "shell",
shellID: shell.id,
command: shell.command,
status: "running",
metadata: { background: true },
time: { created: 1 },
},
],
cursor: {},
})
if (url.pathname === `/api/session/${session.id}/inbox` || url.pathname === `/api/session/${session.id}/permission`)
return json({ data: [] })
if (url.pathname === "/api/shell") return json({ location: { directory }, data: finished ? [] : [shell] })
if (url.pathname === `/api/shell/${shell.id}/output`) {
if (finished) {
failedReads++
return json({ message: "Shell output unavailable" }, { status: 404 })
}
return json({
location: { directory },
data: {
output: partial.slice(Number(url.searchParams.get("cursor") ?? 0)),
cursor: partial.length,
size: partial.length,
truncated: false,
},
})
}
return undefined
}, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
try {
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: {
get: async () => ({ animations: false, tabs: { enabled: false }, session: { sidebar: "hide" } }),
update: async () => ({}),
},
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: session.id },
log: () => {},
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
)
const running = await setup.waitForFrame((frame) => frame.includes(partial.trim()))
expect(running).toContain(shell.command)
expect(running).not.toContain("Background")
finished = true
events.emit({
id: "evt_shell_exited",
created: 2,
type: "shell.exited",
location: { directory },
data: { id: shell.id, status: "exited", exit: 0 },
})
await setup.waitFor(() => failedReads > 0)
events.emit({
id: "evt_shell_ended",
created: 3,
type: "session.shell.ended",
durable: { aggregateID: session.id, seq: 1, version: 1 },
data: {
sessionID: session.id,
shell: { ...shell, status: "exited", exit: 0, time: { started: 1, completed: 2 } },
output: { output: completed, cursor: completed.length, size: completed.length, truncated: false },
},
})
const terminal = await setup.waitForFrame((frame) => frame.includes("last persisted output"))
expect(terminal).toContain(partial.trim())
expect(terminal).toContain(`$ ${shell.command}`)
expect(terminal).not.toContain("Background")
setup.renderer.destroy()
await task
} finally {
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await server.stop()
}
})
test("new session inherits the active session model", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false, kittyKeyboard: true })
setup.renderer.start()
+14 -4
View File
@@ -152,9 +152,7 @@ test("assigns stable IDs to tool rows for direct navigation", () => {
{ type: "text", text: "Starting a shell" },
{ type: "tool", id: "shell-1", name: "shell", state: pending(), time: { created: 2 } },
]),
assistant("assistant-2", [
{ type: "tool", id: "shell-2", name: "shell", state: pending(), time: { created: 3 } },
]),
assistant("assistant-2", [{ type: "tool", id: "shell-2", name: "shell", state: pending(), time: { created: 3 } }]),
]
const rows = reduceSessionRows(messages)
const boundaries = messageBoundaryIDs(rows, messages)
@@ -408,17 +406,28 @@ test("completes exploration groups when another row follows", () => {
test("hides synthetic messages without descriptions", () => {
const messages: SessionMessageInfo[] = [
{
id: "shell-message",
type: "shell",
shellID: "sh_user",
command: "pwd",
status: "exited",
time: { created: 0 },
},
assistant("assistant-1", [{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } }]),
{
type: "synthetic",
id: "synthetic-1",
text: "internal context",
metadata: { source: "shell", shellID: "sh_user", state: "completed" },
time: { created: 2 },
},
assistant("assistant-2", [{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } }]),
]
expect(reduceSessionRows(messages)).toEqual([
const rows = reduceSessionRows(messages)
expect(rows).toEqual([
{ type: "message", messageID: "shell-message" },
{
type: "group",
kind: "exploration",
@@ -430,6 +439,7 @@ test("hides synthetic messages without descriptions", () => {
],
},
])
expect(reduceSessionRows(messages, new Set(["synthetic-1"]))).toEqual(rows)
})
test("renders synthetic messages with descriptions", () => {
+3
View File
@@ -170,6 +170,9 @@ const source = {
"ui.tool.websearch.provider": "{{provider}} Web Search",
"ui.tool.shell": "Shell",
"ui.tool.shell.writingCommand": "Writing command...",
"ui.tool.shell.exit": "Command exited with code {{code}}",
"ui.tool.shell.timeout": "Command timed out",
"ui.tool.shell.cancelled": "Command cancelled",
"ui.tool.execute": "Execute",
"ui.tool.patch": "Patch",
"ui.tool.questions": "Questions",