feat(core): port v2 runtime fixes onto dev

Cherry-picks the packages/core changes from the v2 branch onto dev:
- combined ordered stdout/stderr in AppProcess + bash structured output
- edit/apply-patch return FileDiff info with status and line stats
- ignore no-op model switches; record reasoning timestamps
- return unexpected local tool defects to the model and continue
- keep OAuth account metadata out of request bodies
- nest OpenAI reasoning effort/summary options
- load OpenCode provider config asynchronously; batch plugin boot
- export latest public event manifest

Includes the supporting schema reasoning time field and regenerated
client/SDK types.
This commit is contained in:
Dax Raad 2026-06-27 00:05:29 -04:00
commit 93159bccbf
28 changed files with 385 additions and 130 deletions

View file

@ -1,6 +1,8 @@
export * as ApplyPatchTool from "./apply-patch"
import { ToolFailure } from "@opencode-ai/llm"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { createTwoFilesPatch, diffLines } from "diff"
import { Effect, Layer, Schema } from "effect"
import { FileMutation } from "../file-mutation"
import { FSUtil } from "../fs-util"
@ -24,7 +26,10 @@ export const Applied = Schema.Struct({
target: Schema.String,
})
export const Output = Schema.Struct({ applied: Schema.Array(Applied) })
export const Output = Schema.Struct({
applied: Schema.Array(Applied),
files: Schema.Array(FileDiff.Info),
})
export type Output = typeof Output.Type
export const toModelOutput = (output: Output) =>
@ -36,11 +41,17 @@ export const toModelOutput = (output: Output) =>
].join("\n")
type Prepared =
| (Extract<Patch.Hunk, { readonly type: "add" | "delete" }> & { readonly target: LocationMutation.Target })
| (Extract<Patch.Hunk, { readonly type: "add" | "delete" }> & {
readonly target: LocationMutation.Target
readonly before: string
readonly after: string
})
| (Extract<Patch.Hunk, { readonly type: "update" }> & {
readonly target: LocationMutation.Target
readonly source: Uint8Array
readonly content: string
readonly before: string
readonly after: string
})
export const layer = Layer.effectDiscard(
@ -113,29 +124,36 @@ export const layer = Layer.effectDiscard(
for (const { hunk, target } of targets) {
yield* Effect.gen(function* () {
if (hunk.type === "add") {
prepared.push({ ...hunk, target })
prepared.push({
...hunk,
target,
before: "",
after:
hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`,
})
return
}
if ((yield* fs.stat(target.canonical)).type !== "File") yield* fail(hunk.path)
const source = yield* fs.readFile(target.canonical)
const original = new TextDecoder("utf-8", { ignoreBOM: true }).decode(source)
const before = original.replace(/^\uFEFF/, "")
if (hunk.type === "delete") {
prepared.push({ ...hunk, target })
prepared.push({ ...hunk, target, before, after: "" })
return
}
const source = yield* fs.readFile(target.canonical)
const update = Patch.derive(
hunk.path,
hunk.chunks,
new TextDecoder("utf-8", { ignoreBOM: true }).decode(source),
)
const update = Patch.derive(hunk.path, hunk.chunks, original)
prepared.push({
...hunk,
target,
source,
content: Patch.joinBom(update.content, update.bom),
before,
after: update.content,
})
}).pipe(Effect.mapError(() => fail(hunk.path)))
}
const patchFiles = prepared.map(patchFile)
yield* Effect.forEach(
prepared,
(change) =>
@ -165,7 +183,7 @@ export const layer = Layer.effectDiscard(
}).pipe(Effect.mapError(() => fail(change.path))),
{ discard: true },
)
return { applied }
return { applied, files: patchFiles }
}).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch"))))
},
}),
@ -175,3 +193,19 @@ export const layer = Layer.effectDiscard(
.pipe(Effect.orDie)
}),
)
function patchFile(change: Prepared): typeof FileDiff.Info.Type {
const counts = diffLines(change.before, change.after).reduce(
(result, item) => ({
additions: result.additions + (item.added ? (item.count ?? 0) : 0),
deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
}),
{ additions: 0, deletions: 0 },
)
return {
file: change.target.resource,
patch: createTwoFilesPatch(change.target.resource, change.target.resource, change.before, change.after),
status: change.type === "add" ? "added" : change.type === "delete" ? "deleted" : "modified",
...counts,
}
}

View file

@ -30,16 +30,15 @@ export const Input = Schema.Struct({
}),
})
const Output = Schema.Struct({
command: Schema.String,
cwd: Schema.String,
exitCode: Schema.Number.pipe(Schema.optional),
/** Bounded compact equivalent of stdout/stderr: stderr is labeled when present. */
output: Schema.String,
const StructuredOutput = Schema.Struct({
exit: Schema.Number.pipe(Schema.optional),
truncated: Schema.Boolean,
stdoutTruncated: Schema.Boolean.pipe(Schema.optional),
stderrTruncated: Schema.Boolean.pipe(Schema.optional),
timedOut: Schema.Boolean.pipe(Schema.optional),
timeout: Schema.Boolean.pipe(Schema.optional),
})
const Output = Schema.Struct({
...StructuredOutput.fields,
output: Schema.String,
warnings: Schema.Array(Schema.String).pipe(Schema.optional),
})
@ -47,24 +46,12 @@ type Output = typeof Output.Type
const defaultShell = () => (process.platform === "win32" ? (process.env.COMSPEC ?? "cmd.exe") : "/bin/sh")
const compactOutput = (stdout: string, stderr: string) => {
const output = stdout && stderr ? `${stdout}\n\nstderr:\n${stderr}` : stderr ? `stderr:\n${stderr}` : stdout
return output || "(no output)"
}
const captureNotice = (stdoutTruncated: boolean, stderrTruncated: boolean) => {
if (stdoutTruncated && stderrTruncated) return "[stdout and stderr capture truncated at the in-memory safety limit]"
if (stdoutTruncated) return "[stdout capture truncated at the in-memory safety limit]"
if (stderrTruncated) return "[stderr capture truncated at the in-memory safety limit]"
return undefined
}
const modelOutput = (output: Output) => {
const warnings = output.warnings?.length
? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}`
: ""
if (output.timedOut) return `${output.output}${warnings}\n\nCommand timed out before completion.`
return `${output.output}${warnings}\n\nCommand exited with code ${output.exitCode}.`
if (output.timeout) return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command timed out before completion.`
return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command exited with code ${output.exit}.`
}
const isTimeout = (error: AppProcess.AppProcessError) =>
@ -116,7 +103,16 @@ export const layer = Layer.effectDiscard(
description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows.`,
input: Input,
output: Output,
toModelOutput: ({ output }) => [{ type: "text", text: modelOutput(output) }],
structured: StructuredOutput,
toStructuredOutput: ({ output }) => ({
truncated: output.truncated,
...(output.exit === undefined ? {} : { exit: output.exit }),
...(output.timeout === undefined ? {} : { timeout: output.timeout }),
}),
toModelOutput: ({ output }) => [
{ type: "text", text: output.output },
{ type: "text", text: modelOutput(output) },
],
execute: (input, context) =>
Effect.gen(function* () {
const source = {
@ -163,9 +159,9 @@ export const layer = Layer.effectDiscard(
const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS
const result = yield* appProcess
.run(command, {
combineOutput: true,
timeout: Duration.millis(timeout),
maxOutputBytes: MAX_CAPTURE_BYTES,
maxErrorBytes: MAX_CAPTURE_BYTES,
})
.pipe(
Effect.catchTag("AppProcessError", (error) =>
@ -174,26 +170,22 @@ export const layer = Layer.effectDiscard(
)
if (!result) {
return {
command: input.command,
cwd: target.canonical,
output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
truncated: false,
timedOut: true,
timeout: true,
...(warnings.length ? { warnings } : {}),
}
}
const compact = compactOutput(result.stdout.toString("utf8"), result.stderr.toString("utf8"))
const notice = captureNotice(result.stdoutTruncated, result.stderrTruncated)
const output = result.output?.toString("utf8") || "(no output)"
const notice = result.outputTruncated
? "[output capture truncated at the in-memory safety limit]"
: undefined
return {
command: input.command,
cwd: target.canonical,
exitCode: result.exitCode,
output: notice ? `${compact}\n\n${notice}` : compact,
truncated: result.stdoutTruncated || result.stderrTruncated,
exit: result.exitCode,
output: notice ? `${output}\n\n${notice}` : output,
truncated: result.outputTruncated === true,
...(warnings.length ? { warnings } : {}),
...(result.stdoutTruncated ? { stdoutTruncated: true } : {}),
...(result.stderrTruncated ? { stderrTruncated: true } : {}),
}
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` }))),
}),

View file

@ -7,6 +7,8 @@
export * as EditTool from "./edit"
import { ToolFailure } from "@opencode-ai/llm"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { createTwoFilesPatch, diffLines } from "diff"
import { Effect, Layer, Schema } from "effect"
import { FileMutation } from "../file-mutation"
import { FSUtil } from "../fs-util"
@ -30,10 +32,7 @@ export const Input = Schema.Struct({
})
export const Output = Schema.Struct({
operation: Schema.Literal("write"),
target: Schema.String,
resource: Schema.String,
existed: Schema.Boolean,
files: Schema.Array(FileDiff.Info),
replacements: Schema.Number,
})
export type Output = typeof Output.Type
@ -71,7 +70,7 @@ const previewLines = (value: string, prefix: "+" | "-") => {
export const toModelOutput = (output: Output, oldString: string, newString: string) =>
[
`Edited file successfully: ${output.resource}`,
`Edited file successfully: ${output.files[0]?.file}`,
`Replacements: ${output.replacements}`,
"```diff",
...previewLines(oldString, "-"),
@ -179,6 +178,13 @@ export const layer = Layer.effectDiscard(
input.replaceAll === true
? source.text.replaceAll(oldString, newString)
: source.text.replace(oldString, newString)
const counts = diffLines(source.text, replaced).reduce(
(result, item) => ({
additions: result.additions + (item.added ? (item.count ?? 0) : 0),
deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
}),
{ additions: 0, deletions: 0 },
)
const next = splitBom(replaced)
const result = yield* unableToEdit(
files.writeIfUnchanged({
@ -187,7 +193,17 @@ export const layer = Layer.effectDiscard(
content: joinBom(next.text, source.bom || next.bom),
}),
)
return { ...result, replacements } satisfies Output
return {
files: [
{
file: result.resource,
patch: createTwoFilesPatch(result.resource, result.resource, source.text, replaced),
status: "modified" as const,
...counts,
},
],
replacements,
} satisfies Output
})
},
}),

View file

@ -37,10 +37,19 @@ export type Content =
| { readonly type: "text"; readonly text: string }
| { readonly type: "file"; readonly data: string; readonly mime: string; readonly name?: string }
type Config<Input extends SchemaType<any>, Output extends SchemaType<any>> = {
type Config<
Input extends SchemaType<any>,
Output extends SchemaType<any>,
Structured extends SchemaType<any> = Output,
> = {
readonly description: string
readonly input: Input
readonly output: Output
readonly structured?: Structured
readonly toStructuredOutput?: (input: {
readonly input: Schema.Schema.Type<Input>
readonly output: Output["Encoded"]
}) => Schema.Schema.Type<Structured>
readonly execute: (
input: Schema.Schema.Type<Input>,
context: Context,
@ -59,10 +68,12 @@ type Runtime = {
const runtimes = new WeakMap<AnyTool, Runtime>()
export function make<Input extends SchemaType<any>, Output extends SchemaType<any>>(
config: Config<Input, Output>,
): Definition<Input, Output> {
const tool = Object.freeze({}) as Definition<Input, Output>
export function make<
Input extends SchemaType<any>,
Output extends SchemaType<any>,
Structured extends SchemaType<any> = Output,
>(config: Config<Input, Output, Structured>): Definition<Input, Structured> {
const tool = Object.freeze({}) as Definition<Input, Structured>
const definitions = new Map<string, ToolDefinition>()
runtimes.set(tool, {
definition: (name) => {
@ -72,7 +83,7 @@ export function make<Input extends SchemaType<any>, Output extends SchemaType<an
name,
description: config.description,
inputSchema: toJsonSchema(config.input),
outputSchema: toJsonSchema(config.output),
outputSchema: toJsonSchema(config.structured ?? config.output),
})
definitions.set(name, definition)
return definition
@ -84,6 +95,13 @@ export function make<Input extends SchemaType<any>, Output extends SchemaType<an
config.execute(input, context).pipe(
Effect.flatMap((output) =>
Schema.encodeEffect(config.output)(output).pipe(
Effect.flatMap((output) => {
if (!config.structured || !config.toStructuredOutput)
return Effect.succeed({ output, structured: output })
return Schema.encodeEffect(config.structured)(config.toStructuredOutput({ input, output })).pipe(
Effect.map((structured) => ({ output, structured })),
)
}),
Effect.mapError(
(error) =>
new ToolFailure({
@ -92,8 +110,8 @@ export function make<Input extends SchemaType<any>, Output extends SchemaType<an
),
),
),
Effect.map((output) => ({
structured: output,
Effect.map(({ output, structured }) => ({
structured,
content:
config.toModelOutput?.({ input, output }).map((part) =>
part.type === "text"