mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-17 07:06:23 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4df6412e10 | ||
|
|
c36086d5f7 | ||
|
|
5f3a4a9b6c | ||
|
|
1d86eea81c | ||
|
|
8e09664392 | ||
|
|
c400746dd5 | ||
|
|
e3ce37899d |
@@ -11,6 +11,7 @@ import { SessionContext } from "./context.js"
|
||||
import { SessionGenerate } from "./generate.js"
|
||||
import { SessionHistory } from "./history.js"
|
||||
import { SessionModelHeaders } from "./model-headers.js"
|
||||
import { SessionModelHttp } from "./model-http.js"
|
||||
import { SessionPromptCacheKey } from "./prompt-cache-key.js"
|
||||
import { SessionRunnerModel } from "./runner/model.js"
|
||||
import { SessionSystemPrompt } from "./system-prompt.js"
|
||||
@@ -79,6 +80,13 @@ export const layer = Layer.effect(
|
||||
messages: contextEvent.messages,
|
||||
tools: hookedTools,
|
||||
}),
|
||||
{
|
||||
http: SessionModelHttp.middleware(hooks, {
|
||||
sessionID: selection.session.id,
|
||||
agent: selection.agent.id,
|
||||
model: model.ref,
|
||||
}),
|
||||
},
|
||||
)
|
||||
yield* Effect.logInfo("session generation usage diagnostic", { usage: response.usage })
|
||||
return response.text
|
||||
|
||||
@@ -16,6 +16,7 @@ export const MAX_READ_BYTES = 50 * 1024
|
||||
export const MAX_MEDIA_INGEST_BYTES = 20 * 1024 * 1024
|
||||
const FIRST_CHUNK = 256 * 1024
|
||||
const MAX_LINE_LENGTH = 2_000
|
||||
const TREE_BASE = 6
|
||||
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
|
||||
const MEDIA_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf"])
|
||||
|
||||
@@ -159,19 +160,59 @@ export const read = Effect.fn("ReadTool.read")(function* (
|
||||
}
|
||||
}
|
||||
|
||||
const chunks = [first.bytes]
|
||||
if (first.bytes.length >= first.info.size) {
|
||||
const result = textPage(first.bytes, true, page)
|
||||
if (result === undefined) return yield* Effect.die("Read page did not settle for a complete first chunk")
|
||||
return yield* makeTextPage(input, resource, result, first.bytes.subarray(0, result.consumed).includes(0))
|
||||
}
|
||||
|
||||
const offset = page.offset || 1
|
||||
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
|
||||
const leaves = [textLeaf(first.bytes)]
|
||||
let bytes = first.bytes.length
|
||||
let lines = leaves[0].summary.lines
|
||||
let ended = false
|
||||
while (true) {
|
||||
const bytes = Buffer.concat(chunks)
|
||||
const eof = bytes.length >= first.info.size
|
||||
const result = textPage(bytes, eof, page)
|
||||
if (result !== undefined) return yield* makeTextPage(bytes, input, resource, result)
|
||||
const next = yield* readFile(files, input, resource, { offset: bytes.length, length: FIRST_CHUNK })
|
||||
if (next.bytes.length === 0) {
|
||||
const result = textPage(bytes, true, page)
|
||||
if (result === undefined) return yield* Effect.die("Read page did not settle at EOF")
|
||||
return yield* makeTextPage(bytes, input, resource, result)
|
||||
const eof = ended || bytes >= first.info.size
|
||||
if (lines >= offset - 1 || eof) {
|
||||
const tree = textTree(leaves)
|
||||
const start = textOffset(tree, offset - 1)
|
||||
let position = 0
|
||||
const selected = Buffer.concat(
|
||||
leaves.flatMap((leaf) => {
|
||||
const leafStart = position
|
||||
position += leaf.summary.bytes
|
||||
if (position <= start) return []
|
||||
return [leaf.bytes.subarray(Math.max(0, start - leafStart))]
|
||||
}),
|
||||
)
|
||||
const result = textPage(selected, eof, { limit })
|
||||
if (result !== undefined) {
|
||||
const translated = {
|
||||
...result,
|
||||
offset,
|
||||
...(result.next === undefined ? { next: undefined } : { next: offset + result.next - 1 }),
|
||||
}
|
||||
const consumed = start + result.consumed
|
||||
let checked = 0
|
||||
const binary = leaves.some((leaf) => {
|
||||
const length = Math.min(leaf.summary.bytes, consumed - checked)
|
||||
checked += leaf.summary.bytes
|
||||
return length > 0 && leaf.bytes.subarray(0, length).includes(0)
|
||||
})
|
||||
return yield* makeTextPage(input, resource, translated, binary)
|
||||
}
|
||||
}
|
||||
chunks.push(next.bytes)
|
||||
|
||||
const next = yield* readFile(files, input, resource, { offset: bytes, length: FIRST_CHUNK })
|
||||
if (next.bytes.length === 0) {
|
||||
ended = true
|
||||
continue
|
||||
}
|
||||
const leaf = textLeaf(next.bytes)
|
||||
leaves.push(leaf)
|
||||
bytes += leaf.summary.bytes
|
||||
lines += leaf.summary.lines
|
||||
}
|
||||
})
|
||||
|
||||
@@ -188,12 +229,12 @@ const readFile = (
|
||||
)
|
||||
|
||||
const makeTextPage = Effect.fnUntraced(function* (
|
||||
bytes: Uint8Array,
|
||||
input: AbsolutePath,
|
||||
resource: string,
|
||||
result: NonNullable<ReturnType<typeof textPage>>,
|
||||
binary: boolean,
|
||||
) {
|
||||
if (bytes.subarray(0, result.consumed).includes(0)) return yield* new BinaryFileError({ resource })
|
||||
if (binary) return yield* new BinaryFileError({ resource })
|
||||
if (result.entries.length === 0 && result.offset !== 1)
|
||||
return yield* new OffsetOutOfRangeError({ offset: result.offset })
|
||||
return new TextPage({
|
||||
@@ -274,6 +315,60 @@ const textPage = (bytes: Uint8Array, eof: boolean, page: PageInput) => {
|
||||
return { entries, offset, next, consumed }
|
||||
}
|
||||
|
||||
type TextSummary = { readonly bytes: number; readonly lines: number }
|
||||
// Request-local augmented rope. Subtree byte and newline weights locate a line
|
||||
// like an order-statistic query without repeatedly decoding the accumulated text.
|
||||
// https://doi.org/10.1002/spe.4380251203
|
||||
type TextNode =
|
||||
| { readonly type: "leaf"; readonly bytes: Uint8Array; readonly summary: TextSummary }
|
||||
| { readonly type: "branch"; readonly children: ReadonlyArray<TextNode>; readonly summary: TextSummary }
|
||||
|
||||
const textLeaf = (bytes: Uint8Array): Extract<TextNode, { readonly type: "leaf" }> => {
|
||||
let lines = 0
|
||||
for (const byte of bytes) if (byte === 10) lines++
|
||||
return { type: "leaf", bytes, summary: { bytes: bytes.length, lines } }
|
||||
}
|
||||
|
||||
const textTree = (nodes: ReadonlyArray<TextNode>): TextNode => {
|
||||
if (nodes.length === 1) return nodes[0]
|
||||
return textTree(
|
||||
Array.from({ length: Math.ceil(nodes.length / (TREE_BASE * 2)) }, (_, index) => {
|
||||
const children = nodes.slice(index * TREE_BASE * 2, (index + 1) * TREE_BASE * 2)
|
||||
return {
|
||||
type: "branch" as const,
|
||||
children,
|
||||
summary: {
|
||||
bytes: children.reduce((total, child) => total + child.summary.bytes, 0),
|
||||
lines: children.reduce((total, child) => total + child.summary.lines, 0),
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const textOffset = (tree: TextNode, newline: number) => {
|
||||
if (newline === 0) return 0
|
||||
let node = tree
|
||||
let remaining = newline
|
||||
let offset = 0
|
||||
while (node.type === "branch") {
|
||||
const child = node.children.find((candidate) => {
|
||||
if (remaining <= candidate.summary.lines) return true
|
||||
remaining -= candidate.summary.lines
|
||||
offset += candidate.summary.bytes
|
||||
return false
|
||||
})
|
||||
if (!child) return tree.summary.bytes
|
||||
node = child
|
||||
}
|
||||
for (const [index, byte] of node.bytes.entries()) {
|
||||
if (byte !== 10) continue
|
||||
remaining--
|
||||
if (remaining === 0) return offset + index + 1
|
||||
}
|
||||
return tree.summary.bytes
|
||||
}
|
||||
|
||||
const nthNewline = (bytes: Uint8Array, count: number) => {
|
||||
let found = 0
|
||||
for (const [index, byte] of bytes.entries()) {
|
||||
|
||||
@@ -51,15 +51,17 @@ import { Effect, Layer, Schema, Stream } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const requests: LLMRequest[] = []
|
||||
let hasHttpMiddleware = false
|
||||
let instruction: string | Instructions.Unavailable = "Initial context"
|
||||
const sessionID = SessionSchema.ID.make("ses_generate_test")
|
||||
|
||||
const model = LanguageModel.make({ id: "generate-model", provider: "test", route: OpenAIChat.route })
|
||||
const client = Layer.mock(LLMClient.Service)({
|
||||
stream: () => Stream.die(new Error("unused")),
|
||||
generate: (request) =>
|
||||
generate: (request, options) =>
|
||||
Effect.sync(() => {
|
||||
requests.push(request)
|
||||
hasHttpMiddleware = typeof options?.http === "function"
|
||||
const response = LLMResponse.fromEvents([
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.textStart({ id: "generate" }),
|
||||
@@ -221,6 +223,7 @@ const setup = Effect.gen(function* () {
|
||||
it.effect("generates from fresh settled Session context without durable mutation", () =>
|
||||
Effect.gen(function* () {
|
||||
requests.length = 0
|
||||
hasHttpMiddleware = false
|
||||
instruction = "Initial context"
|
||||
const { db, bus, instructions } = yield* setup
|
||||
yield* InstructionState.prepare(db, bus, instructions, sessionID)
|
||||
@@ -298,6 +301,7 @@ it.effect("generates from fresh settled Session context without durable mutation
|
||||
|
||||
expect(result).toBe("Transient answer")
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(hasHttpMiddleware).toBe(true)
|
||||
expect(requests[0]?.model).toBe(model)
|
||||
expect(requests[0]?.system[0]?.text).toBe("Hooked system")
|
||||
expect(requests[0]?.system.map((part) => part.text)).toContain("Initial context")
|
||||
|
||||
@@ -227,6 +227,21 @@ describe("ReadToolFileSystem", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("checks skipped lines for null bytes", () =>
|
||||
Effect.gen(function* () {
|
||||
const { environment, files, directory } = yield* fixture
|
||||
const file = path.join(directory, "nul-prefix.txt")
|
||||
yield* files.writeFile(file, Uint8Array.from([...new TextEncoder().encode("one"), 0, 10, 116, 119, 111, 10]))
|
||||
|
||||
const error = yield* ReadToolFileSystem.read(environment, absolute(file), "nul-prefix.txt", {
|
||||
offset: 2,
|
||||
limit: 1,
|
||||
}).pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(ReadToolFileSystem.BinaryFileError)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reads page two after fetching more than the first 256KB range", () =>
|
||||
Effect.gen(function* () {
|
||||
const { environment, files, directory } = yield* fixture
|
||||
|
||||
@@ -547,6 +547,12 @@ export function getToolInfo(
|
||||
title: i18n.t("ui.tool.shell"),
|
||||
subtitle: input.command,
|
||||
}
|
||||
case "execute":
|
||||
return {
|
||||
icon: "console",
|
||||
title: i18n.t("ui.tool.execute"),
|
||||
subtitle: input.code,
|
||||
}
|
||||
case "edit":
|
||||
return {
|
||||
icon: "code-lines",
|
||||
@@ -1574,6 +1580,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
||||
if (typeof value === "string" && value) return value
|
||||
return taskId()
|
||||
})
|
||||
const toolError = createMemo(() => partError(part(), i18n.t("ui.toolErrorCard.failed")))
|
||||
|
||||
const render = createMemo(() => ToolRegistry.render(part().tool) ?? GenericTool)
|
||||
const controlledOpen = () => (props.onToolOpenChange ? (props.toolOpen ?? props.defaultOpen) : undefined)
|
||||
@@ -1583,7 +1590,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
||||
<Show when={!hideQuestion()}>
|
||||
<div data-component="tool-part-wrapper" data-timeline-part-id={part().id}>
|
||||
<Switch>
|
||||
<Match when={part().state.status === "error" && (part().state as any).error}>
|
||||
<Match when={toolError()}>
|
||||
{(error) => {
|
||||
const cleaned = error().replace("Error: ", "")
|
||||
if (part().tool === "question" && cleaned.includes("dismissed this question")) {
|
||||
@@ -1644,6 +1651,26 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
||||
)
|
||||
}
|
||||
|
||||
function partError(part: ToolPart, fallback: string) {
|
||||
if (part.state.status === "error") return part.state.error
|
||||
if (part.tool !== "execute" || !("metadata" in part.state)) return undefined
|
||||
const calls = part.state.metadata?.toolCalls
|
||||
const failed =
|
||||
part.state.metadata?.error === true ||
|
||||
(Array.isArray(calls) &&
|
||||
calls.some(
|
||||
(call) =>
|
||||
call !== null &&
|
||||
typeof call === "object" &&
|
||||
!Array.isArray(call) &&
|
||||
"status" in call &&
|
||||
call.status === "error",
|
||||
))
|
||||
if (!failed) return undefined
|
||||
if ("output" in part.state && typeof part.state.output === "string" && part.state.output) return part.state.output
|
||||
return fallback
|
||||
}
|
||||
|
||||
export function MessageDivider(props: { label: string }) {
|
||||
return (
|
||||
<div data-component="compaction-part">
|
||||
@@ -2104,6 +2131,84 @@ ToolRegistry.register({
|
||||
|
||||
ToolRegistry.register({ name: "subagent", render: ToolRegistry.render("task") })
|
||||
|
||||
function ConsoleOutput(props: { copy: string; children: JSX.Element }) {
|
||||
const i18n = useI18n()
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
|
||||
const copy = async () => {
|
||||
if (!props.copy) return
|
||||
if (!(await writeClipboard(props.copy))) return
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-component="bash-output" dir="ltr">
|
||||
<div data-slot="bash-copy">
|
||||
<TooltipV2 value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")} placement="top">
|
||||
<IconButtonV2
|
||||
icon={<IconV2 name={copied() ? "check" : "outline-copy"} size="small" />}
|
||||
size="normal"
|
||||
variant="ghost-muted"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={copy}
|
||||
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
|
||||
/>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
<div
|
||||
data-slot="bash-scroll"
|
||||
data-scrollable
|
||||
tabIndex={0}
|
||||
role="region"
|
||||
aria-label={i18n.t("ui.scrollView.ariaLabel")}
|
||||
>
|
||||
<pre data-slot="bash-pre">
|
||||
<code>{props.children}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
ToolRegistry.register({
|
||||
name: "execute",
|
||||
render(props) {
|
||||
const i18n = useI18n()
|
||||
const pending = () => props.status === "pending" || props.status === "streaming" || props.status === "running"
|
||||
const code = createMemo(() => (typeof props.input.code === "string" ? props.input.code : ""))
|
||||
const text = createMemo(() => {
|
||||
const output = stripAnsi(props.output ?? "").replace(/\r\n?/g, "\n")
|
||||
return `${code()}${output ? "\n\n" + output : ""}`
|
||||
})
|
||||
const sawPending = pending()
|
||||
return (
|
||||
<BasicTool
|
||||
{...props}
|
||||
icon="console"
|
||||
allowOpenWhilePending
|
||||
trigger={(open) => (
|
||||
<div data-slot="basic-tool-tool-info-structured">
|
||||
<span data-slot="basic-tool-tool-indicator">
|
||||
<Icon name="console" size="small" />
|
||||
</span>
|
||||
<div data-slot="basic-tool-tool-info-main">
|
||||
<span data-slot="basic-tool-tool-title">
|
||||
<TextShimmer text={i18n.t("ui.tool.execute")} active={pending()} />
|
||||
</span>
|
||||
<Show when={!open() && code()}>
|
||||
<ShellSubmessage text={code()} animate={sawPending} />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<ConsoleOutput copy={text()}>{text()}</ConsoleOutput>
|
||||
</BasicTool>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
ToolRegistry.register({
|
||||
name: "shell",
|
||||
render(props) {
|
||||
@@ -2116,17 +2221,6 @@ ToolRegistry.register({
|
||||
const out = stripAnsi(props.output || props.metadata.output || "").replace(/\r\n?/g, "\n")
|
||||
return `${command()}${out ? "\n\n" + out : ""}`
|
||||
})
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
|
||||
const handleCopy = async () => {
|
||||
const content = command()
|
||||
if (!content) return
|
||||
if (await writeClipboard(content)) {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<BasicTool
|
||||
{...props}
|
||||
@@ -2145,36 +2239,12 @@ ToolRegistry.register({
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div data-component="bash-output" dir="ltr">
|
||||
<div data-slot="bash-copy">
|
||||
<TooltipV2 value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")} placement="top">
|
||||
<IconButtonV2
|
||||
icon={<IconV2 name={copied() ? "check" : "outline-copy"} size="small" />}
|
||||
size="normal"
|
||||
variant="ghost-muted"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={handleCopy}
|
||||
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
|
||||
/>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
<div
|
||||
data-slot="bash-scroll"
|
||||
data-scrollable
|
||||
tabIndex={0}
|
||||
role="region"
|
||||
aria-label={i18n.t("ui.scrollView.ariaLabel")}
|
||||
>
|
||||
<pre data-slot="bash-pre">
|
||||
<code>
|
||||
<span data-slot="bash-prompt" aria-hidden="true">
|
||||
{"$ "}
|
||||
</span>
|
||||
{text()}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
<ConsoleOutput copy={command()}>
|
||||
<span data-slot="bash-prompt" aria-hidden="true">
|
||||
{"$ "}
|
||||
</span>
|
||||
{text()}
|
||||
</ConsoleOutput>
|
||||
</BasicTool>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -71,8 +71,9 @@ describe("partDefaultOpen", () => {
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("preserves shell defaults", () => {
|
||||
test("applies shell defaults to console tools", () => {
|
||||
expect(partDefaultOpen(tool("shell", {}), true, false)).toBe(true)
|
||||
expect(partDefaultOpen(tool("execute", {}), true, false)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ function deletionOnly(part: ToolPart) {
|
||||
|
||||
export function partDefaultOpen(part: PartType, shell = false, edit = false): boolean | undefined {
|
||||
if (part.type !== "tool") return undefined
|
||||
if (part.tool === "bash" || part.tool === "shell") return shell
|
||||
if (part.tool === "bash" || part.tool === "shell" || part.tool === "execute") return shell
|
||||
if (part.tool === "edit" || part.tool === "write" || part.tool === "patch" || part.tool === "apply_patch") {
|
||||
if (!edit) return false
|
||||
return !deletionOnly(part)
|
||||
|
||||
@@ -54,6 +54,7 @@ export function ToolErrorCard(props: ToolErrorCardProps) {
|
||||
websearch: "ui.tool.websearch",
|
||||
bash: "ui.tool.shell",
|
||||
shell: "ui.tool.shell",
|
||||
execute: "ui.tool.execute",
|
||||
patch: "ui.tool.patch",
|
||||
apply_patch: "ui.tool.patch",
|
||||
question: "ui.tool.questions",
|
||||
|
||||
@@ -158,6 +158,7 @@ const source = {
|
||||
"ui.tool.websearch": "Web Search",
|
||||
"ui.tool.websearch.provider": "{{provider}} Web Search",
|
||||
"ui.tool.shell": "Shell",
|
||||
"ui.tool.execute": "Execute",
|
||||
"ui.tool.patch": "Patch",
|
||||
"ui.tool.questions": "Questions",
|
||||
"ui.tool.questions.numbered": "Questions {{number}}",
|
||||
|
||||
Reference in New Issue
Block a user