Compare commits

...

5 Commits

Author SHA1 Message Date
Aiden Cline 3dffa01054 chore: merge v2 2026-07-23 17:41:37 -05:00
Kit Langton e7ecee5df2 fix(core): isolate tool hook outcomes (#38571) 2026-07-23 17:40:42 -04:00
Aiden Cline 1957e167dc fix(core): return write diffs 2026-07-23 17:11:35 +00:00
Aiden Cline a8d2e8fa81 fix(tui): highlight written lines 2026-07-23 16:54:23 +00:00
Aiden Cline d0aae842a1 fix(tui): preview written file content 2026-07-23 16:47:10 +00:00
11 changed files with 149 additions and 59 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ export type JsonSchema = {
/** Either a validating Effect Schema or a render-only JSON Schema document. */
export type SchemaType = Schema.Decoder<unknown> | JsonSchema
/** Executable tool tool exposed through CodeMode's `tools` object. */
/** Executable tool exposed through CodeMode's `tools` object. */
export type Tool<R = never> = {
readonly _tag: "CodeModeTool"
readonly description: string
+13 -6
View File
@@ -44,6 +44,11 @@ export interface WriteResult {
readonly existed: boolean
}
export interface TextWriteResult extends WriteResult {
readonly before: string
readonly after: string
}
export interface RemoveResult {
readonly operation: "remove"
readonly target: string
@@ -56,7 +61,7 @@ export interface Interface {
readonly create: (input: WriteInput) => Effect.Effect<WriteResult, TargetExistsError | FSUtil.Error>
readonly write: (input: WriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
/** Write text while retaining an existing UTF-8 BOM and emitting at most one BOM. */
readonly writeTextPreservingBom: (input: TextWriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
readonly writeTextPreservingBom: (input: TextWriteInput) => Effect.Effect<TextWriteResult, FSUtil.Error>
/** Commit only if an existing target still has the expected bytes. */
readonly writeIfUnchanged: (
input: ConditionalWriteInput,
@@ -112,11 +117,13 @@ const layer = Layer.effect(
const current = yield* fs
.readFile(input.target.canonical)
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
yield* fs.writeWithDirs(
input.target.canonical,
joinBom(next.text, Boolean(current && hasUtf8Bom(current)) || next.bom),
)
return writeResult(input.target, current !== undefined)
const content = joinBom(next.text, Boolean(current && hasUtf8Bom(current)) || next.bom)
yield* fs.writeWithDirs(input.target.canonical, content)
return {
...writeResult(input.target, current !== undefined),
before: current ? new TextDecoder().decode(current).replace(/^\uFEFF/, "") : "",
after: content.replace(/^\uFEFF/, ""),
}
}),
),
)
+10 -14
View File
@@ -394,19 +394,15 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
})
}
return toolHooks.hook.after((event) => {
// JS plugin boundary: marshal the canonical outcome out, copy mutations back.
const output: Record<string, unknown> = {
// Decode first so plugin mutations cannot alias the canonical outcome.
const output = {
tool: event.tool,
sessionID: event.sessionID,
agent: event.agent,
messageID: event.messageID,
callID: event.callID,
input: event.input,
status: event.status,
content: event.content,
metadata: event.metadata,
outputPaths: event.outputPaths,
...(event.status === "error" ? { error: event.error } : {}),
...Schema.decodeUnknownSync(Tool.ExecuteAfterOutcome)(event),
}
return Reflect.apply(callback, undefined, [output]).pipe(
Effect.tap(() => {
@@ -417,16 +413,16 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
return Effect.logWarning("ignoring execute.after tool status change", { tool: event.tool })
return Effect.sync(() => {
if (event.status === "completed" && decoded.value.status === "completed") {
if (output.content !== event.content) event.content = decoded.value.content
if (output.metadata !== event.metadata) event.metadata = decoded.value.metadata
if (output.outputPaths !== event.outputPaths) event.outputPaths = decoded.value.outputPaths
event.content = decoded.value.content
event.metadata = decoded.value.metadata
event.outputPaths = decoded.value.outputPaths
return
}
if (event.status === "error" && decoded.value.status === "error") {
if (output.error !== event.error) event.error = decoded.value.error
if (output.content !== event.content) event.content = decoded.value.content
if (output.metadata !== event.metadata) event.metadata = decoded.value.metadata
if (output.outputPaths !== event.outputPaths) event.outputPaths = decoded.value.outputPaths
event.error = decoded.value.error
event.content = decoded.value.content
event.metadata = decoded.value.metadata
event.outputPaths = decoded.value.outputPaths
}
})
}),
+1 -1
View File
@@ -130,7 +130,7 @@ const layer = Layer.effect(
// Durable publishes are serialized so tool fibers and step settlement never interleave
// mid-event.
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
const publish = (event: LLMEvent, error?: SessionError.Error) => serialized(publisher.publish(event, error))
const publish = (event: LLMEvent) => serialized(publisher.publish(event))
let overflowFailure: ProviderErrorEvent | undefined
const providerStream = llm.stream(prepared.request).pipe(
Stream.runForEach((event) =>
@@ -1,5 +1,5 @@
import { type LLMEvent, type ProviderMetadata, type ToolContent, type ToolResultValue } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
import { type LLMEvent, type ProviderMetadata, type ToolResultValue } from "@opencode-ai/ai"
import { Effect } from "effect"
import { EventV2 } from "../../event"
import { ModelV2 } from "../../model"
import { SessionEvent } from "../event"
@@ -12,7 +12,6 @@ import { Snapshot } from "../../snapshot"
import { RelativePath } from "../../schema"
import { SessionUsage } from "../usage"
import { Tool } from "../../tool/tool"
import { MAX_BYTES } from "../../tool-output-store"
import type { ToolRegistry } from "../../tool/registry"
type Input = {
@@ -28,9 +27,11 @@ const record = (value: unknown): Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : { value }
/** Derives canonical model content from a provider-hosted tool result. */
const hostedContent = (result: ToolResultValue): readonly [ToolContent, ...ToolContent[]] => {
if (result.type === "content" && result.value.length > 0)
return result.value as unknown as readonly [ToolContent, ...ToolContent[]]
const hostedContent = (result: ToolResultValue): Tool.NonEmptyContent => {
if (result.type === "content") {
const content = Tool.nonEmpty(result.value)
if (content !== undefined) return content
}
return [{ type: "text", text: Tool.stringify(result.value) }]
}
@@ -47,11 +48,8 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
progress?: ToolRegistry.Progress
}
>()
const failureSnapshot = (tool: { readonly progress?: ToolRegistry.Progress }) => {
if (!tool.progress) return {}
const metadata = Tool.jsonMetadata(tool.progress, MAX_BYTES)
return metadata === undefined ? {} : { metadata }
}
const failureSnapshot = (tool: { readonly progress?: ToolRegistry.Progress }) =>
tool.progress === undefined ? {} : { metadata: tool.progress }
let assistantMessageID = input.assistantMessageID
let stepStarted = false
let stepFailed = false
@@ -292,7 +290,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
return tool ? Effect.succeed(tool.assistantMessageID) : Effect.die(new Error(`Unknown tool call: ${callID}`))
}
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (event: LLMEvent, error?: SessionError.Error) {
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (event: LLMEvent) {
switch (event.type) {
case "step-start":
yield* startAssistant()
@@ -405,12 +403,12 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
tool.settled = true
const executed = event.providerExecuted === true || tool.providerExecuted
const resultState = providerState(event.providerMetadata)
if (error !== undefined || event.result.type === "error") {
if (event.result.type === "error") {
yield* events.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID: event.id,
error: error ?? { type: "tool.execution", message: Tool.stringify(event.result.value) },
error: { type: "tool.execution", message: Tool.stringify(event.result.value) },
...failureSnapshot(tool),
executed,
resultState,
@@ -471,13 +469,12 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
const tool = tools.get(callID)
if (!tool?.called || tool.settled)
return yield* Effect.die(new Error(`Tool progress outside running call: ${callID}`))
const current = { ...update }
tool.progress = current
tool.progress = update
yield* events.publish(SessionEvent.Tool.Progress, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID,
metadata: current,
metadata: update,
})
})
+25 -1
View File
@@ -8,6 +8,8 @@ export * as WriteTool from "./write"
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import { ToolFailure } from "@opencode-ai/ai"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { createTwoFilesPatch, diffLines } from "diff"
import { Effect, Schema } from "effect"
import { FileMutation } from "../file-mutation"
import { LocationMutation } from "../location-mutation"
@@ -30,6 +32,7 @@ export const Output = Schema.Struct({
target: Schema.String,
resource: Schema.String,
existed: Schema.Boolean,
files: Schema.Array(FileDiff.Info),
})
export type Output = typeof Output.Type
@@ -82,7 +85,28 @@ export const Plugin = {
agent: context.agent,
source,
})
return yield* files.writeTextPreservingBom({ target, content: input.content })
const result = yield* files.writeTextPreservingBom({ target, content: input.content })
const counts = diffLines(result.before, result.after).reduce(
(total, item) => ({
additions: total.additions + (item.added ? (item.count ?? 0) : 0),
deletions: total.deletions + (item.removed ? (item.count ?? 0) : 0),
}),
{ additions: 0, deletions: 0 },
)
return {
operation: result.operation,
target: result.target,
resource: result.resource,
existed: result.existed,
files: [
{
file: result.resource,
patch: createTwoFilesPatch(result.resource, result.resource, result.before, result.after),
status: result.existed ? "modified" : "added",
...counts,
},
],
} satisfies Output
}).pipe(
Effect.map((output) => ({ output, content: toModelOutput(output) })),
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
+7 -2
View File
@@ -80,9 +80,14 @@ describe("FileMutation", () => {
const created = yield* (yield* LocationMutation.Service).resolve({ path: "created.txt" })
const files = yield* FileMutation.Service
yield* files.writeTextPreservingBom({ target: preserved, content: "\uFEFFafter" })
yield* files.writeTextPreservingBom({ target: created, content: "\uFEFF\uFEFF\uFEFFcreated" })
const preservedResult = yield* files.writeTextPreservingBom({ target: preserved, content: "\uFEFFafter" })
const createdResult = yield* files.writeTextPreservingBom({
target: created,
content: "\uFEFF\uFEFF\uFEFFcreated",
})
expect(preservedResult).toMatchObject({ existed: true, before: "before", after: "after" })
expect(createdResult).toMatchObject({ existed: false, before: "", after: "created" })
expect(yield* Effect.promise(() => fs.readFile(preservedPath, "utf8"))).toBe("\uFEFFafter")
expect(yield* Effect.promise(() => fs.readFile(created.canonical, "utf8"))).toBe("\uFEFFcreated")
}).pipe(provide(directory)),
+1 -1
View File
@@ -365,7 +365,7 @@ describe("PluginV2", () => {
yield* ctx.tool
.hook("execute.after", (event) =>
Effect.sync(() => {
if (event.status === "completed") event.content = [] as never
if (event.status === "completed") (event.content as unknown as unknown[]).splice(0)
}),
)
.pipe(Effect.asVoid)
@@ -118,9 +118,7 @@ test("provider-executed success derives content and retains provider result stat
test("interrupted progress metadata remains in the terminal failure snapshot", async () => {
const { published, publisher } = capture("anthropic", { interruptProgress: true })
await Effect.runPromise(publisher.publish(call))
const exit = await Effect.runPromiseExit(
publisher.progress(call.id, { phase: "visible" }),
)
const exit = await Effect.runPromiseExit(publisher.progress(call.id, { phase: "visible" }))
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
await Effect.runPromise(publisher.failUnsettledTools({ type: "aborted", message: "interrupted" }))
@@ -129,6 +127,18 @@ test("interrupted progress metadata remains in the terminal failure snapshot", a
})
})
test("failure snapshot retains canonical progress above the default byte limit", async () => {
const { published, publisher } = capture("anthropic", { interruptProgress: true })
await Effect.runPromise(publisher.publish(call))
const detail = "x".repeat(60 * 1024)
await Effect.runPromiseExit(publisher.progress(call.id, { detail }))
await Effect.runPromise(publisher.failUnsettledTools({ type: "aborted", message: "interrupted" }))
expect(published.find((event) => event.type === "session.tool.failed.2")?.data).toMatchObject({
metadata: { detail },
})
})
test("failure before progress omits partial output fields", async () => {
const { published, publisher } = capture()
await Effect.runPromise(publisher.publish(call))
+24 -2
View File
@@ -120,13 +120,21 @@ describe("WriteTool", () => {
Effect.gen(function* () {
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["write"])
const settled = yield* executeTool(registry, call({ path: "src/new.txt", content: "created" }))
expect(settled).toEqual({
expect(settled).toMatchObject({
status: "completed",
output: {
operation: "write",
target: path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt"),
resource: "src/new.txt",
existed: false,
files: [
{
file: "src/new.txt",
status: "added",
additions: 1,
deletions: 0,
},
],
},
content: [{ type: "text", text: "Created file successfully: src/new.txt" }],
})
@@ -156,7 +164,21 @@ describe("WriteTool", () => {
expect(settled.status).toBe("completed")
if (settled.status !== "completed") return
expect(settled.content).toEqual([{ type: "text", text: "Wrote file successfully: existing.txt" }])
expect(settled.output).toMatchObject({ resource: "existing.txt", existed: true })
expect(settled.output).toMatchObject({
resource: "existing.txt",
existed: true,
files: [
{
file: "existing.txt",
status: "modified",
additions: 1,
deletions: 1,
},
],
})
const output = settled.output as WriteTool.Output
expect(output.files[0]?.patch).toContain("-before")
expect(output.files[0]?.patch).toContain("+after")
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "existing.txt"), "utf8"))).toBe(
"after",
)
+40 -11
View File
@@ -62,6 +62,7 @@ import parsers from "../../parsers-config"
import { errorMessage } from "../../util/error"
import { useToast } from "../../ui/toast"
import stripAnsi from "strip-ansi"
import { createTwoFilesPatch } from "diff"
import { usePromptRef } from "../../context/prompt"
import { projectedPromptInput } from "../../prompt/codec"
import { useEpilogue } from "../../context/epilogue"
@@ -2693,28 +2694,56 @@ function Shell(props: ToolProps) {
}
function Write(props: ToolProps) {
const ctx = use()
const { themeV2, syntax } = useTheme()
const pathFormatter = usePathFormatter()
const code = createMemo(() => {
return stringValue(props.input.content) ?? ""
})
const file = createMemo(() => parseApplyPatchFiles(props.metadata.files)[0])
const patch = createMemo(
() => file()?.patch ?? createTwoFilesPatch("", stringValue(props.input.path) ?? "", "", code()),
)
const complete = createMemo(() => props.part.state.status === "completed")
const view = createMemo(() => {
if (ctx.config.diffs?.view === "unified") return "unified"
if (ctx.config.diffs?.view === "split") return "split"
return ctx.width > 120 ? "split" : "unified"
})
return (
<Switch>
<Match when={props.metadata.diagnostics !== undefined}>
<Match when={complete()}>
<BlockTool
path={{ label: "# Wrote", value: pathFormatter.format(stringValue(props.input.path)) }}
path={{
label: props.metadata.existed === false ? "# Created" : "# Wrote",
value: pathFormatter.format(stringValue(props.input.path)),
}}
part={props.part}
>
<line_number fg={themeV2.text.subdued} minWidth={3} paddingRight={1}>
<code
conceal={false}
fg={themeV2.text.default}
filetype={filetype(stringValue(props.input.path))}
syntaxStyle={syntax()}
content={code()}
/>
</line_number>
<Show when={code() || file()?.additions || file()?.deletions}>
<box paddingLeft={1}>
<diff
diff={patch()}
view={view()}
filetype={filetype(stringValue(props.input.path))}
syntaxStyle={syntax()}
showLineNumbers={true}
width="100%"
wrapMode={ctx.diffWrapMode()}
fg={themeV2.text.default}
addedBg={themeV2.diff.background.added}
removedBg={themeV2.diff.background.removed}
contextBg={themeV2.diff.background.context}
addedSignColor={themeV2.diff.highlight.added}
removedSignColor={themeV2.diff.highlight.removed}
lineNumberFg={themeV2.diff.lineNumber.text}
lineNumberBg={themeV2.diff.background.context}
addedLineNumberBg={themeV2.diff.lineNumber.background.added}
removedLineNumberBg={themeV2.diff.lineNumber.background.removed}
/>
</box>
</Show>
<Diagnostics diagnostics={props.metadata.diagnostics} filePath={stringValue(props.input.path) ?? ""} />
</BlockTool>
</Match>