diff --git a/packages/client/src/solid/data.ts b/packages/client/src/solid/data.ts index f792cf9be2c..4d95036e8ef 100644 --- a/packages/client/src/solid/data.ts +++ b/packages/client/src/solid/data.ts @@ -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 }, }) }) diff --git a/packages/client/test/solid-data.test.ts b/packages/client/test/solid-data.test.ts index 1ebbca4518c..e1cc880d7f3 100644 --- a/packages/client/test/solid-data.test.ts +++ b/packages/client/test/solid-data.test.ts @@ -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) { const listeners = new Set[0]>() const api = OpenCode.make({ diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index b80e575b357..ad03d3111a0 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -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() - const shellLocks = KeyedMutex.makeUnsafe() 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[0], messageID: SessionMessage.ID, diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index 64a2943bf7a..458719cd8e7 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -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 "" } diff --git a/packages/core/src/session/execution/restart.ts b/packages/core/src/session/execution/restart.ts index 1e28ca4f8a4..9a85393cd86 100644 --- a/packages/core/src/session/execution/restart.ts +++ b/packages/core/src/session/execution/restart.ts @@ -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: `\n${text}\n`, - metadata: { - source: "shell", + ...ShellResult.notification({ jobID: background.id, shellID: recovery.shellID, + command: recovery.command, state, - }, + text, + }), ...(suspended.has(recovery.sessionID) ? { resume: false } : {}), }) .pipe( diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index 4aadfba36a0..490b4db56af 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -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, diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index acc6965d7ed..8b5d767f7f6 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -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, diff --git a/packages/core/src/shell.ts b/packages/core/src/shell.ts index 429d3b7f152..508e50454f6 100644 --- a/packages/core/src/shell.ts +++ b/packages/core/src/shell.ts @@ -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()("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 + // A known shell's terminal state and bounded tail. Missing capture remains distinct from its exit status. + readonly result: (started: Shell.Info) => Effect.Effect // Replaces the running command's timeout from now; zero clears it. readonly timeout: (id: Shell.ID, duration: number) => Effect.Effect readonly output: (id: Shell.ID, input?: Shell.OutputInput) => Effect.Effect @@ -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() @@ -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* ( input: CreateInput, before?: (input: ShellCreateBefore) => Effect.Effect, @@ -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, ], }) diff --git a/packages/core/src/shell/result.ts b/packages/core/src/shell/result.ts new file mode 100644 index 00000000000..4c377e779b7 --- /dev/null +++ b/packages/core/src/shell/result.ts @@ -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) { + 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: `\n${input.text}\n`, + 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}` } +} diff --git a/packages/core/src/tool/plugin/shell.ts b/packages/core/src/tool/plugin/shell.ts index 70e4d47af30..2a240d63251 100644 --- a/packages/core/src/tool/plugin/shell.ts +++ b/packages/core/src/tool/plugin/shell.ts @@ -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: `\n${text}\n`, 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() + 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() - 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)), diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts index 8c743d76a34..ed3c2a40ab1 100644 --- a/packages/core/test/session-compaction.test.ts +++ b/packages/core/test/session-compaction.test.ts @@ -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: "" }, ]) diff --git a/packages/core/test/session-execution.test.ts b/packages/core/test/session-execution.test.ts index 6735291e83f..ebf303b8f6c 100644 --- a/packages/core/test/session-execution.test.ts +++ b/packages/core/test/session-execution.test.ts @@ -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: '\n(no output)\n\nCommand exited with code 7.\n', }, }, ]) + expect(inbox[0]).toHaveProperty("payload.metadata", { + source: "shell", + jobID: "call-completed-shell", + shellID: "sh_completed", + state: "completed", + }) expect(yield* restarted.pendingBackground).toEqual([]) }), ) diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index 214f779e67f..a28ea34f7e9 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -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) }, }) diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index 462e3f6c919..5f37a4d19aa 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -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({ diff --git a/packages/core/test/session-shell.test.ts b/packages/core/test/session-shell.test.ts new file mode 100644 index 00000000000..3306cb0563f --- /dev/null +++ b/packages/core/test/session-shell.test.ts @@ -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 + release: Deferred.Deferred + wakes: Session.ID[] + } +>()("test/SessionShell/ExecutionControl") {} + +const controlLayer = Layer.effect( + ExecutionControl, + Effect.gen(function* () { + return { started: yield* Deferred.make(), release: yield* Deferred.make(), wakes: [] } + }), +) + +const executionLayer = Layer.effect( + SessionExecution.Service, + Effect.gen(function* () { + const control = yield* ExecutionControl + const coordinator = yield* SessionRunCoordinator.make({ + 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, 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([]) + }), + ) +}) diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index 25155590f1a..36ceed7f802 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -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)), diff --git a/packages/session-ui/component-tests/session-shell.spec.ts b/packages/session-ui/component-tests/session-shell.spec.ts new file mode 100644 index 00000000000..d63b88b939b --- /dev/null +++ b/packages/session-ui/component-tests/session-shell.spec.ts @@ -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) + }) +} diff --git a/packages/session-ui/src/timeline/rows-current.test.ts b/packages/session-ui/src/timeline/rows-current.test.ts index 991c7e7f688..3a8fae326b1 100644 --- a/packages/session-ui/src/timeline/rows-current.test.ts +++ b/packages/session-ui/src/timeline/rows-current.test.ts @@ -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" }) diff --git a/packages/session-ui/src/timeline/terminal-work.stories.tsx b/packages/session-ui/src/timeline/terminal-work.stories.tsx index 083f43c0699..6a22f7809f0 100644 --- a/packages/session-ui/src/timeline/terminal-work.stories.tsx +++ b/packages/session-ui/src/timeline/terminal-work.stories.tsx @@ -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({ + id: "msg_shell_live", + type: "shell", + shellID: "shell_live", + command: "printf ready", + status: "running", + time: { created: 1 }, + }) + let output = args.output ? "ready\n" : "" + return ( +
+ + + { + 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, + }, + } + }} + > + + +
+ ) + }, +} + const CollapsedShell = { render: () => ( + 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 (
+ + {(error) => } +
) } diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 27e78b1c058..ff05284da17 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -2329,26 +2329,21 @@ function RevertMessage(props: { } function ShellMessage(props: { message: Extract }) { - 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 ( - - $ {props.message.command} - - {output()} - - + ) } @@ -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 ( + ) +} + +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 ( - + Writing command… ) : ( Writing command… @@ -3304,7 +3324,7 @@ function Shell(props: ToolProps) { {limitedOutput()} - + Background diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index 9faa64dd6ac..d310820f1bd 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -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() diff --git a/packages/tui/test/cli/tui/session-rows.test.ts b/packages/tui/test/cli/tui/session-rows.test.ts index 0efa465906f..bbab0257ec0 100644 --- a/packages/tui/test/cli/tui/session-rows.test.ts +++ b/packages/tui/test/cli/tui/session-rows.test.ts @@ -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", () => { diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index c49ee2ff42d..31a7cebd2b7 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -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",