Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 32 additions & 14 deletions packages/opencode/src/tool/shell.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Effect, Stream } from "effect"
import { Effect, Fiber, Stream } from "effect"
import os from "os"
import { createWriteStream } from "node:fs"
import * as Tool from "./tool"
Expand Down Expand Up @@ -446,6 +446,20 @@ export const ShellTool = Tool.define(
let cut = false
let expired = false
let aborted = false
let outputVersion = 0
let publishedVersion = 0

const publish = Effect.fnUntraced(function* () {
if (publishedVersion === outputVersion) return
const version = outputVersion
const output = last
yield* ctx.metadata({
metadata: {
output,
},
})
publishedVersion = version
})

const closeSink = Effect.fnUntraced(function* () {
const stream = sink
Expand Down Expand Up @@ -483,7 +497,14 @@ export const ShellTool = Tool.define(
yield* Effect.addFinalizer(closeSink)
const handle = yield* spawner.spawn(cmd(input.shell, input.command, input.cwd, input.env))

yield* Effect.forkScoped(
const publisher = yield* Effect.gen(function* () {
while (true) {
yield* Effect.sleep("100 millis")
yield* publish()
}
}).pipe(Effect.forkScoped)

const output = yield* Effect.forkScoped(
Stream.runForEach(Stream.decodeText(handle.all), (chunk) => {
const size = Buffer.byteLength(chunk, "utf-8")
list.push({ text: chunk, size })
Expand All @@ -496,6 +517,7 @@ export const ShellTool = Tool.define(
}

last = preview(last + chunk)
outputVersion++

if (file) {
sink?.write(chunk)
Expand All @@ -511,22 +533,11 @@ export const ShellTool = Tool.define(
full = ""
}),
),
Effect.andThen(
ctx.metadata({
metadata: {
output: last,
},
}),
),
)
}
}

return ctx.metadata({
metadata: {
output: last,
},
})
return Effect.void
}),
)

Expand Down Expand Up @@ -554,6 +565,13 @@ export const ShellTool = Tool.define(
yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie)
}

yield* Fiber.await(output).pipe(
Effect.asVoid,
Effect.timeoutOrElse({ duration: "100 millis", orElse: () => Fiber.interrupt(output) }),
)
yield* Fiber.interrupt(publisher)
yield* publish()

return exit.kind === "exit" ? exit.code : null
}),
).pipe(Effect.orDie)
Expand Down
81 changes: 80 additions & 1 deletion packages/opencode/test/tool/shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1112,7 +1112,7 @@ describe("tool.shell abort", () => {
const updates: string[] = []
const result = yield* run(
{
command: `echo first && sleep 0.1 && echo second`,
command: `echo first && sleep 0.25 && echo second`,
},
{
...ctx,
Expand All @@ -1129,6 +1129,85 @@ describe("tool.shell abort", () => {
}),
),
)

it.live("coalesces frequent metadata updates without losing output", () =>
runIn(
projectRoot,
Effect.gen(function* () {
const updates: string[] = []
const code = `for(let i=0;i<40;i++){process.stdout.write(i+String.fromCharCode(10));await Bun.sleep(10)}`
const result = yield* run(
{
command: `${bin} -e ${evalarg(code)}`,
},
{
...ctx,
metadata: (input) =>
Effect.sync(() => {
updates.push(((input.metadata as { output?: string })?.output ?? "").trim())
}),
},
)

expect(result.output).toContain("0")
expect(result.output).toContain("39")
expect(updates.at(-1)).toContain("39")
expect(updates.length).toBeGreaterThan(1)
expect(updates.length).toBeLessThan(15)
}),
),
)

it.live("flushes final output before returning and publishes nothing afterward", () =>
runIn(
projectRoot,
Effect.gen(function* () {
const updates: string[] = []
const result = yield* run(
{ command: fill("bytes", 1) },
{
...ctx,
metadata: (input) =>
Effect.sync(() => {
updates.push((input.metadata as { output?: string })?.output ?? "")
}),
},
)

expect(result.output).toBe("a")
expect(result.metadata.output).toBe("a")
expect(updates[0]).toBe("")
expect(updates.at(-1)).toBe("a")
const count = updates.length
yield* Effect.sleep("150 millis")
expect(updates).toHaveLength(count)
}),
),
)

it.live("does not publish repeated metadata for a quiet command", () =>
runIn(
projectRoot,
Effect.gen(function* () {
const updates: string[] = []
const result = yield* run(
{ command: fill("bytes", 0) },
{
...ctx,
metadata: (input) =>
Effect.sync(() => {
updates.push((input.metadata as { output?: string })?.output ?? "")
}),
},
)

expect(result.output).toBe("(no output)")
expect(result.metadata.output).toBe("(no output)")
expect(result.metadata.exit).toBe(0)
expect(updates).toEqual([""])
}),
),
)
})

describe("tool.shell truncation", () => {
Expand Down
Loading