mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-07 09:39:46 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 941b3fc7bb | |||
| a39b20ee36 |
@@ -134,22 +134,6 @@ test("does not pull a keyboard-scrolled user during shell remeasurement", async
|
||||
await reportVisualStability(testInfo, "keyboard-during-resize", trace, anchorPlan(regions))
|
||||
})
|
||||
|
||||
test("accumulates rapid page key presses", async ({ page }) => {
|
||||
await setupTimeline(page, {
|
||||
messages: history(80),
|
||||
viewport: { width: 1400, height: 700 },
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
|
||||
await scroller.focus()
|
||||
const before = await scroller.evaluate((element) => ({ top: element.scrollTop, height: element.clientHeight }))
|
||||
|
||||
for (let index = 0; index < 3; index++) await scroller.press("PageUp")
|
||||
|
||||
await page.waitForTimeout(150)
|
||||
expect(before.top - (await scroller.evaluate((element) => element.scrollTop))).toBeGreaterThan(before.height * 2.2)
|
||||
})
|
||||
|
||||
test("tracks keyboard scrolling from a focused timeline descendant", async ({ page }, testInfo) => {
|
||||
const shellID = "prt_descendant_keyboard_01_shell"
|
||||
const timeline = await setupTimeline(page, {
|
||||
|
||||
@@ -46,7 +46,6 @@ import { SessionGenerateNode } from "./session/generate-node"
|
||||
import { McpTool } from "./tool/mcp"
|
||||
import { ReadToolFileSystem } from "./tool/read-filesystem"
|
||||
import { Tool } from "./tool"
|
||||
import { ToolOutput } from "./tool-output"
|
||||
import { Vcs } from "./vcs"
|
||||
|
||||
export { LocationServiceMap } from "./location-service-map"
|
||||
@@ -79,7 +78,6 @@ const locationServiceNodes = [
|
||||
MCP.node,
|
||||
Permission.node,
|
||||
Tool.node,
|
||||
ToolOutput.node,
|
||||
Image.node,
|
||||
SkillInstructions.node,
|
||||
ReferenceInstructions.node,
|
||||
|
||||
@@ -2,9 +2,14 @@ export * as SessionRestart from "./restart"
|
||||
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Bus } from "../../bus"
|
||||
import { SessionEvent } from "../event"
|
||||
import { SessionExecution } from "../execution"
|
||||
import { SessionStore } from "../store"
|
||||
|
||||
const CONTINUE_AFTER_SERVER_RESTART =
|
||||
"The server restarted while you were working. Continue from where you left off without repeating completed work."
|
||||
|
||||
export interface Interface {
|
||||
/**
|
||||
* Marks every execution active in this process for resumption by the next server start.
|
||||
@@ -26,6 +31,7 @@ export const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const store = yield* SessionStore.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const bus = yield* Bus.Service
|
||||
return Service.of({
|
||||
suspendActiveSessions: Effect.gen(function* () {
|
||||
yield* store.suspend(yield* execution.active)
|
||||
@@ -37,6 +43,11 @@ export const layer = Layer.effect(
|
||||
(sessionID) =>
|
||||
Effect.gen(function* () {
|
||||
if (!(yield* store.consumeSuspended(sessionID))) return
|
||||
yield* bus.publish(SessionEvent.Synthetic, {
|
||||
sessionID,
|
||||
text: CONTINUE_AFTER_SERVER_RESTART,
|
||||
description: "Continuing after restart",
|
||||
})
|
||||
// Drain failures are already logged and durably recorded by the execution layer.
|
||||
yield* Effect.ignore(execution.resume(sessionID))
|
||||
}),
|
||||
@@ -47,4 +58,8 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [SessionStore.node, SessionExecution.node] })
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [SessionStore.node, SessionExecution.node, Bus.node],
|
||||
})
|
||||
|
||||
@@ -32,7 +32,6 @@ import { StepFailedError } from "../error"
|
||||
import { toSessionError } from "../to-session-error"
|
||||
import { SessionRunnerRetry } from "./retry"
|
||||
import { SessionUsage } from "../usage"
|
||||
import { ToolOutput } from "../../tool-output"
|
||||
|
||||
/** How one model call ended: settled, awaiting a scheduled retry, or restarted by compaction. */
|
||||
type CallOutcome = Data.TaggedEnum<{
|
||||
@@ -108,7 +107,6 @@ const layer = Layer.effect(
|
||||
const db = (yield* Database.Service).db
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
const title = yield* SessionTitle.Service
|
||||
const toolOutput = yield* ToolOutput.Service
|
||||
// Title generation is a side effect of a successful step; it must not delay continuation.
|
||||
// The in-flight set coalesces overlapping steps while title presence records success durably.
|
||||
const titlesRunning = new Set<SessionSchema.ID>()
|
||||
@@ -336,7 +334,6 @@ const layer = Layer.effect(
|
||||
).pipe(
|
||||
// The fiber owns its call: it publishes its own completion, masked so a
|
||||
// finished execution always reaches its durable settlement.
|
||||
Effect.flatMap(toolOutput.truncate),
|
||||
Effect.flatMap((outcome) => publisher.toolExecution(event.id, event.name, outcome)),
|
||||
Effect.catchTag("Tool.Error", (error) =>
|
||||
publisher.failTool(event.id, toSessionError(error)).pipe(Effect.asVoid),
|
||||
@@ -565,7 +562,6 @@ export const node = makeLocationNode({
|
||||
SessionCompaction.node,
|
||||
SessionTitle.node,
|
||||
Snapshot.node,
|
||||
ToolOutput.node,
|
||||
Database.node,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
export * as ToolOutput from "./tool-output"
|
||||
|
||||
import path from "path"
|
||||
import type { Tool } from "@opencode-ai/schema/tool"
|
||||
import { Context, Duration, Effect, Layer, Schedule } from "effect"
|
||||
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Config } from "./config"
|
||||
import { Identifier } from "./id/id"
|
||||
|
||||
export const MAX_LINES = 2_000
|
||||
export const MAX_BYTES = 50 * 1024 // 50 KiB
|
||||
export const RETENTION = Duration.days(7)
|
||||
export const DIRECTORY = "tool-output"
|
||||
|
||||
type Result = Tool.Result
|
||||
|
||||
export interface Interface {
|
||||
readonly truncate: (result: Result) => Effect.Effect<Result>
|
||||
readonly cleanup: () => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ToolOutput") {}
|
||||
|
||||
const cleanup = Effect.fn("ToolOutput.cleanup")(function* (fs: FSUtil.Interface, directory: string) {
|
||||
const cutoff = Identifier.timestamp(
|
||||
Identifier.create("tool", "ascending", Date.now() - Duration.toMillis(RETENTION)),
|
||||
)
|
||||
const entries = yield* fs.readDirectory(directory).pipe(
|
||||
Effect.map((entries) => entries.filter((entry) => /^tool_[0-9a-f]{12}/.test(entry))),
|
||||
Effect.catch(() => Effect.succeed([])),
|
||||
)
|
||||
for (const entry of entries) {
|
||||
if (Identifier.timestamp(entry) >= cutoff) continue
|
||||
yield* fs.remove(path.join(directory, entry)).pipe(Effect.catch(() => Effect.void))
|
||||
}
|
||||
})
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const directory = path.join(global.data, DIRECTORY)
|
||||
|
||||
const truncate = Effect.fn("ToolOutput.truncate")(function* (result: Result) {
|
||||
if (result.metadata?.truncated !== undefined) return result
|
||||
const content =
|
||||
typeof result.content === "string" ? [{ type: "text" as const, text: result.content }] : (result.content ?? [])
|
||||
const text = content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n")
|
||||
const configured = Config.latest(yield* config.entries(), "tool_output")
|
||||
const maxLines = configured?.max_lines ?? MAX_LINES
|
||||
const maxBytes = configured?.max_bytes ?? MAX_BYTES
|
||||
const lines = text.split("\n")
|
||||
if (text.endsWith("\n")) lines.pop()
|
||||
const totalBytes = Buffer.byteLength(text, "utf-8")
|
||||
if (lines.length <= maxLines && totalBytes <= maxBytes)
|
||||
return { ...result, metadata: { ...result.metadata, truncated: false } }
|
||||
|
||||
const kept: string[] = []
|
||||
let bytes = 0
|
||||
let hitBytes = false
|
||||
for (const line of lines.slice(0, maxLines)) {
|
||||
const size = Buffer.byteLength(line, "utf-8") + (kept.length > 0 ? 1 : 0)
|
||||
if (bytes + size > maxBytes) {
|
||||
hitBytes = true
|
||||
break
|
||||
}
|
||||
kept.push(line)
|
||||
bytes += size
|
||||
}
|
||||
if (!hitBytes && kept.length === lines.length && totalBytes > bytes) hitBytes = true
|
||||
const removed = hitBytes ? totalBytes - bytes : lines.length - kept.length
|
||||
const unit = hitBytes ? (removed === 1 ? "byte" : "bytes") : removed === 1 ? "line" : "lines"
|
||||
const file = path.join(directory, Identifier.ascending("tool"))
|
||||
yield* fs.ensureDir(directory).pipe(Effect.orDie)
|
||||
yield* fs.writeFileString(file, text).pipe(Effect.orDie)
|
||||
const marker = `... ${removed} ${unit} truncated; full content saved to ${file} ...`
|
||||
const bounded: Tool.Content[] = []
|
||||
let remaining = kept.join("\n").length
|
||||
let seenText = false
|
||||
let marked = false
|
||||
for (const item of content) {
|
||||
if (item.type === "file") {
|
||||
bounded.push(item)
|
||||
continue
|
||||
}
|
||||
if (seenText && remaining > 0) remaining--
|
||||
seenText = true
|
||||
if (remaining >= item.text.length) {
|
||||
bounded.push(item)
|
||||
remaining -= item.text.length
|
||||
continue
|
||||
}
|
||||
if (remaining > 0) bounded.push({ ...item, text: item.text.slice(0, remaining) })
|
||||
if (!marked) bounded.push({ type: "text", text: marker })
|
||||
remaining = 0
|
||||
marked = true
|
||||
}
|
||||
if (!marked) bounded.push({ type: "text", text: marker })
|
||||
return {
|
||||
...result,
|
||||
content: bounded,
|
||||
metadata: { ...result.metadata, truncated: true, outputPath: file },
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({ truncate, cleanup: () => cleanup(fs, directory) })
|
||||
}),
|
||||
)
|
||||
|
||||
const cleanupLayer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
yield* cleanup(fs, path.join(global.data, DIRECTORY)).pipe(
|
||||
Effect.repeat(Schedule.spaced(Duration.hours(1))),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const cleanupNode = makeGlobalNode({ name: "tool-output-cleanup", layer: cleanupLayer, deps: [FSUtil.node, Global.node] })
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Config.node, FSUtil.node, Global.node, cleanupNode],
|
||||
})
|
||||
@@ -114,7 +114,6 @@ export const Plugin = {
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
content: toModelContent(input.path, input.offset, output),
|
||||
metadata: { truncated: output.type === "file" ? false : output.truncated },
|
||||
})),
|
||||
Effect.mapError((error) => {
|
||||
if (error instanceof ToolFailure) return error
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { Content } from "@opencode-ai/schema/tool"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Deferred, Effect, Schema, Scope } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Config } from "../../config"
|
||||
import { LocationMutation } from "../../location-mutation"
|
||||
import { Permission } from "../../permission"
|
||||
import { PluginRuntime } from "../../plugin/runtime"
|
||||
@@ -14,10 +13,10 @@ import { NonNegativeInt } from "../../schema"
|
||||
import { SessionSchema } from "../../session/schema"
|
||||
import { Shell } from "../../shell"
|
||||
import { ShellParse } from "../../shell/parse"
|
||||
import { ToolOutput } from "../../tool-output"
|
||||
|
||||
export const name = "shell"
|
||||
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
|
||||
export const MAX_CAPTURE_BYTES = 1024 * 1024
|
||||
|
||||
const BACKGROUND_STARTED = "The command was moved to the background."
|
||||
const BACKGROUND_INSTRUCTION =
|
||||
@@ -87,7 +86,6 @@ export const Plugin = {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const shell = yield* Shell.Service
|
||||
const permission = yield* Permission.Service
|
||||
const config = yield* Config.Service
|
||||
|
||||
const notifyWhenDone = Effect.fn("ShellTool.notifyWhenDone")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
@@ -193,21 +191,15 @@ export const Plugin = {
|
||||
yield* context.progress({ shellID: info.id })
|
||||
|
||||
const captureShell = Effect.fn("ShellTool.captureShell")(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 truncated = latest.size > MAX_CAPTURE_BYTES
|
||||
const page = yield* shell.output(info.id, {
|
||||
cursor: Math.max(0, latest.size - maxBytes),
|
||||
limit: maxBytes,
|
||||
cursor: Math.max(0, latest.size - MAX_CAPTURE_BYTES),
|
||||
limit: MAX_CAPTURE_BYTES,
|
||||
})
|
||||
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}]` : ""
|
||||
return {
|
||||
output: `${output || "(no output)"}${notice}`,
|
||||
output: `${page.output || "(no output)"}${notice}`,
|
||||
truncated,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
|
||||
import { UserInterruptedError } from "@opencode-ai/core/session/error"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionRunner } from "@opencode-ai/core/session/runner"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
@@ -127,23 +128,34 @@ describe("SessionExecution lifecycle", () => {
|
||||
it.effect("resumes each suspended Session at most once", () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
const first = Session.ID.make("ses_resume_first")
|
||||
const second = Session.ID.make("ses_resume_second")
|
||||
yield* seedSessions(database, [first, second], { time_suspended: Date.now() })
|
||||
|
||||
const drained: string[] = []
|
||||
const continued: SessionEvent.Synthetic[] = []
|
||||
const scope = yield* Scope.make()
|
||||
const context = yield* buildExecution(scope, ({ sessionID }) => Effect.sync(() => void drained.push(sessionID)))
|
||||
const execution = Context.get(context, SessionExecution.Service)
|
||||
const restart = Context.get(context, SessionRestart.Service)
|
||||
yield* bus.project(SessionEvent.Synthetic, (event) => Effect.sync(() => void continued.push(event)))
|
||||
|
||||
yield* restart.resumeSuspendedSessions
|
||||
yield* Effect.forEach([first, second], execution.awaitIdle, { discard: true })
|
||||
expect(drained.toSorted()).toEqual([first, second])
|
||||
expect(continued.map((event) => event.data).toSorted((a, b) => a.sessionID.localeCompare(b.sessionID))).toEqual(
|
||||
[first, second].map((sessionID) => ({
|
||||
sessionID,
|
||||
text: "The server restarted while you were working. Continue from where you left off without repeating completed work.",
|
||||
description: "Continuing after restart",
|
||||
})),
|
||||
)
|
||||
expect(yield* suspensions(database)).toEqual({ [first]: false, [second]: false })
|
||||
|
||||
yield* restart.resumeSuspendedSessions
|
||||
expect(drained.length).toBe(2)
|
||||
expect(continued.length).toBe(2)
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Document, Info } from "@opencode-ai/schema/config"
|
||||
import { ConfigToolOutput } from "@opencode-ai/schema/config/tool-output"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { ToolOutput } from "@opencode-ai/core/tool-output"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Identifier } from "@opencode-ai/core/id/id"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
const withStore = <A, E, R>(
|
||||
body: (output: ToolOutput.Interface, fs: FSUtil.Interface, root: string) => Effect.Effect<A, E, R>,
|
||||
info = new Info(),
|
||||
) =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
const config = Layer.succeed(
|
||||
Config.Service,
|
||||
Config.Service.of({
|
||||
entries: () => Effect.succeed([new Document({ type: "document", info })]),
|
||||
changes: () => Stream.empty,
|
||||
}),
|
||||
)
|
||||
const layer = AppNodeBuilder.build(LayerNode.group([ToolOutput.node, FSUtil.node]), [
|
||||
[Config.node, config],
|
||||
[Global.node, Global.layerWith({ data: tmp.path })],
|
||||
])
|
||||
return Effect.gen(function* () {
|
||||
return yield* body(yield* ToolOutput.Service, yield* FSUtil.Service, tmp.path)
|
||||
}).pipe(Effect.provide(layer))
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
|
||||
describe("ToolOutput", () => {
|
||||
it.live("writes oversized text and returns a bounded preview", () =>
|
||||
withStore(
|
||||
(service, fs) =>
|
||||
Effect.gen(function* () {
|
||||
const output = { items: [1, 2, 3] }
|
||||
const result = yield* service.truncate({ output, content: "one\ntwo\nthree" })
|
||||
expect(result.output).toBe(output)
|
||||
expect(result.metadata).toMatchObject({ truncated: true })
|
||||
const outputPath = result.metadata?.outputPath
|
||||
expect(typeof outputPath).toBe("string")
|
||||
if (typeof outputPath !== "string") return
|
||||
expect(yield* fs.readFileString(outputPath)).toBe("one\ntwo\nthree")
|
||||
expect(result.content).toEqual([
|
||||
{ type: "text", text: "one\ntwo" },
|
||||
{ type: "text", text: `... 1 line truncated; full content saved to ${outputPath} ...` },
|
||||
])
|
||||
}),
|
||||
new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reports bytes omitted by the byte limit", () =>
|
||||
withStore(
|
||||
(output) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* output.truncate({ content: "one\ntwo" })
|
||||
expect(result.content).toEqual([
|
||||
{ type: "text", text: "one" },
|
||||
{
|
||||
type: "text",
|
||||
text: expect.stringMatching(/^\.\.\. 4 bytes truncated; full content saved to .+ \.\.\.$/),
|
||||
},
|
||||
])
|
||||
}),
|
||||
new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 100, max_bytes: 5 }) }),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("preserves mixed content ordering", () =>
|
||||
withStore(
|
||||
(output) =>
|
||||
Effect.gen(function* () {
|
||||
const file = { type: "file" as const, uri: "file:///image.png", mime: "image/png" }
|
||||
const result = yield* output.truncate({
|
||||
content: [{ type: "text", text: "before" }, file, { type: "text", text: "after\nomitted" }],
|
||||
})
|
||||
expect(result.content).toEqual([
|
||||
{ type: "text", text: "before" },
|
||||
file,
|
||||
{ type: "text", text: "after" },
|
||||
{ type: "text", text: expect.stringMatching(/^\.\.\. 1 line truncated; full content saved to /) },
|
||||
])
|
||||
}),
|
||||
new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("skips results that report a truncation state", () =>
|
||||
withStore((output) =>
|
||||
Effect.gen(function* () {
|
||||
const truncated = { content: "one\ntwo", metadata: { truncated: true, source: "tool" } }
|
||||
const retained = { content: "one\ntwo", metadata: { truncated: false, source: "tool" } }
|
||||
expect(yield* output.truncate(truncated)).toBe(truncated)
|
||||
expect(yield* output.truncate(retained)).toBe(retained)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("marks results that fit without changing their content", () =>
|
||||
withStore((output) =>
|
||||
Effect.gen(function* () {
|
||||
const content = [{ type: "text" as const, text: "small" }]
|
||||
expect(yield* output.truncate({ content })).toEqual({ content, metadata: { truncated: false } })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("does not count a trailing newline as another line", () =>
|
||||
withStore(
|
||||
(output) =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* output.truncate({ content: "one\ntwo\n" })).toEqual({
|
||||
content: "one\ntwo\n",
|
||||
metadata: { truncated: false },
|
||||
})
|
||||
}),
|
||||
new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reports a trailing newline omitted by the byte limit", () =>
|
||||
withStore(
|
||||
(output) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* output.truncate({ content: "one\n" })
|
||||
expect(result.content).toEqual([
|
||||
{ type: "text", text: "one" },
|
||||
{ type: "text", text: expect.stringMatching(/^\.\.\. 1 byte truncated; full content saved to /) },
|
||||
])
|
||||
}),
|
||||
new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 3 }) }),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("removes expired managed files", () =>
|
||||
withStore((output, fs, root) =>
|
||||
Effect.gen(function* () {
|
||||
const directory = path.join(root, ToolOutput.DIRECTORY)
|
||||
const old = path.join(
|
||||
directory,
|
||||
Identifier.create("tool", "ascending", Date.now() - 8 * 24 * 60 * 60 * 1_000),
|
||||
)
|
||||
const recent = path.join(directory, Identifier.ascending("tool"))
|
||||
yield* fs.ensureDir(directory)
|
||||
yield* fs.writeFileString(old, "old")
|
||||
yield* fs.writeFileString(recent, "recent")
|
||||
yield* output.cleanup()
|
||||
expect(yield* fs.exists(old)).toBe(false)
|
||||
expect(yield* fs.exists(recent)).toBe(true)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -311,7 +311,9 @@ describe("ReadTool", () => {
|
||||
})
|
||||
expect(settled.status).toBe("completed")
|
||||
if (settled.status !== "completed") return
|
||||
expect(settled.metadata).toEqual({ truncated: false })
|
||||
// Image base64 is carried by the content file item only; read produces no
|
||||
// metadata, so the original bytes are never persisted twice.
|
||||
expect(settled.metadata).toBeUndefined()
|
||||
expect(settled.content).toMatchObject([
|
||||
{ type: "text", text: "Image read successfully" },
|
||||
{ type: "file", mime: "image/png", uri: `data:image/png;base64,${png}` },
|
||||
@@ -729,7 +731,6 @@ describe("ReadTool", () => {
|
||||
output: { entries: listResult.entries, truncated: true, next: 4 },
|
||||
})
|
||||
if (result.status !== "completed") return
|
||||
expect(result.metadata).toEqual({ truncated: true })
|
||||
expect(result.content).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
@@ -804,7 +805,6 @@ describe("ReadTool", () => {
|
||||
output: { type: "text-page", content: "hello", mime: "text/plain", offset: 2, truncated: true, next: 3 },
|
||||
})
|
||||
if (result.status !== "completed") return
|
||||
expect(result.metadata).toEqual({ truncated: true })
|
||||
expect(result.content).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
|
||||
@@ -30,7 +30,6 @@ import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||
import { Shell } from "@opencode-ai/core/shell"
|
||||
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
|
||||
import { ShellTool } from "@opencode-ai/core/tool/plugin/shell"
|
||||
import { ToolOutput } from "@opencode-ai/core/tool-output"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
@@ -172,9 +171,6 @@ const overflowCommand = (bytes: number) =>
|
||||
isWindows
|
||||
? `[Console]::Out.Write('output-start' + ('x' * ${bytes}) + 'output-end'); Start-Sleep -Milliseconds 100`
|
||||
: `printf output-start; head -c ${bytes} /dev/zero | tr '\\0' 'x'; printf output-end`
|
||||
const lineOverflowCommand = isWindows
|
||||
? "[Console]::Out.Write('one' + [Environment]::NewLine + 'two' + [Environment]::NewLine + 'three')"
|
||||
: "printf 'one\\ntwo\\nthree'"
|
||||
const progressOverflowCommand = (bytes: number, release: string) =>
|
||||
isWindows
|
||||
? `[Console]::Out.Write(('x' * ${bytes})); while (!(Test-Path -LiteralPath '${release}')) { Start-Sleep -Milliseconds 50 }`
|
||||
@@ -481,7 +477,7 @@ describe("ShellTool", () => {
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
const bytes = ToolOutput.MAX_BYTES + 1024
|
||||
const bytes = ShellTool.MAX_CAPTURE_BYTES + 1024
|
||||
return withSession(tmp.path, (registry) =>
|
||||
executeTool(registry, call({ command: overflowCommand(bytes) }, "call-overflow")),
|
||||
).pipe(
|
||||
@@ -505,33 +501,6 @@ describe("ShellTool", () => {
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
it.live("uses configured line limits", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(tmp.path, "opencode.json"),
|
||||
JSON.stringify({ tool_output: { max_lines: 2, max_bytes: 1_000 } }),
|
||||
),
|
||||
)
|
||||
const settled = yield* withSession(tmp.path, (registry) =>
|
||||
executeTool(registry, call({ command: lineOverflowCommand }, "call-line-overflow")),
|
||||
)
|
||||
expect(settled.metadata).toMatchObject({ exit: 0, truncated: true })
|
||||
const content = settled.content?.[0]
|
||||
if (!content || content.type !== "text") throw new Error("Expected text content")
|
||||
expect(content.text).not.toContain("one")
|
||||
expect(content.text).toStartWith("two\nthree")
|
||||
expect(content.text).toContain("output truncated; full output saved to:")
|
||||
})
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"reports the shell ID for a running command",
|
||||
() =>
|
||||
@@ -546,7 +515,7 @@ describe("ShellTool", () => {
|
||||
const observed = yield* Deferred.make<string>()
|
||||
yield* executeTool(registry, {
|
||||
...call(
|
||||
{ command: progressOverflowCommand(ToolOutput.MAX_BYTES + 1024, release) },
|
||||
{ command: progressOverflowCommand(ShellTool.MAX_CAPTURE_BYTES + 1024, release) },
|
||||
"call-progress",
|
||||
),
|
||||
progress: (update) =>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { canScrollKey, createKeyboardScroll, scrollKey, scrollTopFromThumbPointer } from "./scroll-view"
|
||||
import { canScrollKey, scrollKey, scrollTopFromThumbPointer } from "./scroll-view"
|
||||
|
||||
describe("scrollKey", () => {
|
||||
test("maps plain navigation keys", () => {
|
||||
@@ -39,53 +39,6 @@ describe("canScrollKey", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("createKeyboardScroll", () => {
|
||||
test("accumulates repeated page movement and settles quickly", () => {
|
||||
const harness = keyboardScrollHarness(0)
|
||||
|
||||
harness.scroll.move(800)
|
||||
harness.advance(30)
|
||||
harness.scroll.move(800)
|
||||
harness.advance(150)
|
||||
|
||||
expect(harness.element.scrollTop).toBe(1_600)
|
||||
})
|
||||
|
||||
test("reverses from the current position instead of the queued target", () => {
|
||||
const harness = keyboardScrollHarness(1_000)
|
||||
|
||||
harness.scroll.move(800)
|
||||
harness.advance(30)
|
||||
const current = harness.element.scrollTop
|
||||
harness.scroll.move(-800)
|
||||
harness.advance(150)
|
||||
|
||||
expect(harness.element.scrollTop).toBe(current - 800)
|
||||
})
|
||||
})
|
||||
|
||||
function keyboardScrollHarness(scrollTop: number) {
|
||||
const element = { scrollTop, clientHeight: 1_000, scrollHeight: 10_000 }
|
||||
const callbacks = new Map<number, FrameRequestCallback>()
|
||||
let time = 0
|
||||
let handle = 0
|
||||
const scroll = createKeyboardScroll(element, {
|
||||
now: () => time,
|
||||
requestFrame: (callback) => {
|
||||
callbacks.set(++handle, callback)
|
||||
return handle
|
||||
},
|
||||
cancelFrame: (id) => callbacks.delete(id),
|
||||
})
|
||||
const advance = (next: number) => {
|
||||
time = next
|
||||
const queued = [...callbacks.values()]
|
||||
callbacks.clear()
|
||||
queued.forEach((callback) => callback(time))
|
||||
}
|
||||
return { element, scroll, advance }
|
||||
}
|
||||
|
||||
describe("scrollTopFromThumbPointer", () => {
|
||||
test("keeps downward thumb movement monotonic when content height changes", () => {
|
||||
const first = scrollTopFromThumbPointer({
|
||||
|
||||
@@ -79,56 +79,6 @@ export function isScrollKeyTarget(target: EventTarget | null, key: NonNullable<R
|
||||
return true
|
||||
}
|
||||
|
||||
export function createKeyboardScroll(
|
||||
element: Pick<HTMLElement, "scrollTop" | "scrollHeight" | "clientHeight">,
|
||||
options: {
|
||||
now?: () => number
|
||||
requestFrame?: (callback: FrameRequestCallback) => number
|
||||
cancelFrame?: (handle: number) => void
|
||||
duration?: number
|
||||
} = {},
|
||||
) {
|
||||
const now = options.now ?? (() => performance.now())
|
||||
const requestFrame = options.requestFrame ?? ((callback) => requestAnimationFrame(callback))
|
||||
const cancelFrame = options.cancelFrame ?? ((handle) => cancelAnimationFrame(handle))
|
||||
const duration = options.duration ?? 120
|
||||
let frame: number | undefined
|
||||
let start = 0
|
||||
let from = element.scrollTop
|
||||
let target = element.scrollTop
|
||||
let direction = 0
|
||||
|
||||
const tick = (time: number) => {
|
||||
const progress = Math.min(1, (time - start) / duration)
|
||||
element.scrollTop = from + (target - from) * (1 - Math.pow(1 - progress, 3))
|
||||
if (progress < 1) {
|
||||
frame = requestFrame(tick)
|
||||
return
|
||||
}
|
||||
frame = undefined
|
||||
}
|
||||
|
||||
const move = (amount: number) => {
|
||||
const nextDirection = Math.sign(amount)
|
||||
const base = frame !== undefined && direction === nextDirection ? target : element.scrollTop
|
||||
if (frame !== undefined) cancelFrame(frame)
|
||||
from = element.scrollTop
|
||||
target = Math.max(0, Math.min(base + amount, element.scrollHeight - element.clientHeight))
|
||||
direction = nextDirection
|
||||
start = now()
|
||||
frame = requestFrame(tick)
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
if (frame !== undefined) cancelFrame(frame)
|
||||
frame = undefined
|
||||
target = element.scrollTop
|
||||
direction = 0
|
||||
}
|
||||
|
||||
return { move, cancel }
|
||||
}
|
||||
|
||||
export function scrollTopFromThumbPointer(input: {
|
||||
pointer: number
|
||||
viewportTop: number
|
||||
@@ -203,7 +153,6 @@ export function ScrollView(props: ScrollViewProps) {
|
||||
const showThumb = () => state.showThumb
|
||||
|
||||
let scrollIdleTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let keyboardScroll: ReturnType<typeof createKeyboardScroll> | undefined
|
||||
|
||||
const markScrolling = () => {
|
||||
setState("isScrolling", true)
|
||||
@@ -219,7 +168,6 @@ export function ScrollView(props: ScrollViewProps) {
|
||||
|
||||
onCleanup(() => {
|
||||
if (scrollIdleTimer !== undefined) clearTimeout(scrollIdleTimer)
|
||||
keyboardScroll?.cancel()
|
||||
})
|
||||
|
||||
const updateThumb = () => {
|
||||
@@ -254,7 +202,6 @@ export function ScrollView(props: ScrollViewProps) {
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
keyboardScroll = createKeyboardScroll(viewportRef)
|
||||
if (local.viewportRef) {
|
||||
local.viewportRef(viewportRef)
|
||||
}
|
||||
@@ -359,30 +306,26 @@ export function ScrollView(props: ScrollViewProps) {
|
||||
switch (next) {
|
||||
case "page-down":
|
||||
e.preventDefault()
|
||||
keyboardScroll?.move(scrollAmount)
|
||||
viewportRef.scrollBy({ top: scrollAmount, behavior: "smooth" })
|
||||
break
|
||||
case "page-up":
|
||||
e.preventDefault()
|
||||
keyboardScroll?.move(-scrollAmount)
|
||||
viewportRef.scrollBy({ top: -scrollAmount, behavior: "smooth" })
|
||||
break
|
||||
case "home":
|
||||
e.preventDefault()
|
||||
keyboardScroll?.cancel()
|
||||
viewportRef.scrollTo({ top: 0, behavior: "smooth" })
|
||||
break
|
||||
case "end":
|
||||
e.preventDefault()
|
||||
keyboardScroll?.cancel()
|
||||
viewportRef.scrollTo({ top: viewportRef.scrollHeight, behavior: "smooth" })
|
||||
break
|
||||
case "up":
|
||||
e.preventDefault()
|
||||
keyboardScroll?.cancel()
|
||||
viewportRef.scrollBy({ top: -lineAmount, behavior: "smooth" })
|
||||
break
|
||||
case "down":
|
||||
e.preventDefault()
|
||||
keyboardScroll?.cancel()
|
||||
viewportRef.scrollBy({ top: lineAmount, behavior: "smooth" })
|
||||
break
|
||||
}
|
||||
@@ -412,7 +355,6 @@ export function ScrollView(props: ScrollViewProps) {
|
||||
if (typeof events.onScroll === "function") events.onScroll(e as any)
|
||||
}}
|
||||
onWheel={(e) => {
|
||||
keyboardScroll?.cancel()
|
||||
markScrolling()
|
||||
const handler = events.onWheel
|
||||
if (typeof handler === "function") handler(e as any)
|
||||
|
||||
Reference in New Issue
Block a user