mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
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:
@@ -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 },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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}` }
|
||||
}
|
||||
@@ -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)),
|
||||
|
||||
@@ -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: "" },
|
||||
])
|
||||
|
||||
@@ -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([])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user