Преглед изворни кода

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.
Dax Raad пре 1 месец
родитељ
комит
93159bccbf

+ 1 - 0
bun.lock

@@ -317,6 +317,7 @@
         "ai-gateway-provider": "3.1.2",
         "bun-pty": "0.4.8",
         "cross-spawn": "catalog:",
+        "diff": "catalog:",
         "drizzle-orm": "catalog:",
         "effect": "catalog:",
         "fuzzysort": "3.1.0",

+ 2 - 0
packages/client/src/generated/types.ts

@@ -512,6 +512,7 @@ export type SessionsContextOutput = {
               readonly id: string
               readonly text: string
               readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
+              readonly time?: { readonly created: number; readonly completed?: number }
             }
           | {
               readonly type: "tool"
@@ -1589,6 +1590,7 @@ export type SessionsMessageOutput = {
               readonly id: string
               readonly text: string
               readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
+              readonly time?: { readonly created: number; readonly completed?: number }
             }
           | {
               readonly type: "tool"

+ 1 - 0
packages/core/package.json

@@ -104,6 +104,7 @@
     "ai-gateway-provider": "3.1.2",
     "bun-pty": "0.4.8",
     "cross-spawn": "catalog:",
+    "diff": "catalog:",
     "drizzle-orm": "catalog:",
     "effect": "catalog:",
     "fuzzysort": "3.1.0",

+ 17 - 14
packages/core/src/plugin/internal.ts

@@ -25,6 +25,7 @@ import { Npm } from "../npm"
 import { PluginV2 } from "../plugin"
 import { Reference } from "../reference"
 import { SkillV2 } from "../skill"
+import { State } from "../state"
 import { FetchHttpClient, HttpClient } from "effect/unstable/http"
 import { AgentPlugin } from "./agent"
 import { CommandPlugin } from "./command"
@@ -104,20 +105,22 @@ const layer = Layer.effectDiscard(
       return plugin.add(PluginV2.ID.make(loaded.id), loaded.effect)
     }
 
-    yield* Effect.gen(function* () {
-      yield* add(ConfigReferencePlugin.Plugin)
-      yield* add(AgentPlugin.Plugin)
-      yield* add(CommandPlugin.Plugin)
-      yield* add(SkillPlugin.Plugin)
-      yield* add(ModelsDevPlugin)
-      yield* add(ConfigAgentPlugin.Plugin)
-      yield* add(ConfigCommandPlugin.Plugin)
-      yield* add(ConfigSkillPlugin.Plugin)
-      for (const item of ProviderPlugins) yield* add(item)
-      yield* add(ConfigExternalPlugin.Plugin)
-      yield* add(ConfigProviderPlugin.Plugin)
-      yield* add(VariantPlugin.Plugin)
-    }).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true }))
+    yield* State.batch(
+      Effect.gen(function* () {
+        yield* add(ConfigReferencePlugin.Plugin)
+        yield* add(AgentPlugin.Plugin)
+        yield* add(CommandPlugin.Plugin)
+        yield* add(SkillPlugin.Plugin)
+        yield* add(ModelsDevPlugin)
+        yield* add(ConfigAgentPlugin.Plugin)
+        yield* add(ConfigCommandPlugin.Plugin)
+        yield* add(ConfigSkillPlugin.Plugin)
+        for (const item of ProviderPlugins) yield* add(item)
+        yield* add(ConfigExternalPlugin.Plugin)
+        yield* add(ConfigProviderPlugin.Plugin)
+        yield* add(VariantPlugin.Plugin)
+      }),
+    ).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true }))
   }),
 )
 

+ 6 - 3
packages/core/src/plugin/provider/opencode.ts

@@ -1,4 +1,4 @@
-import { Duration, Effect, Schema, Stream } from "effect"
+import { Duration, Effect, Schema, Semaphore, Stream } from "effect"
 import type { Scope } from "effect"
 import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
 import { define } from "@opencode-ai/plugin/v2/effect/plugin"
@@ -79,6 +79,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | S
   effect: Effect.fn(function* (ctx) {
     const events = yield* EventV2.Service
     const http = yield* HttpClient.HttpClient
+    const loading = Semaphore.makeUnsafe(1)
     let connected = false
     let providers: typeof ConfigV1.Info.Type.provider | undefined
 
@@ -105,7 +106,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | S
       draft.method.update({ integrationID: "opencode", method: { type: "key", label: "API key (service account)" } })
     })
 
-    yield* load()
+    connected = (yield* ctx.integration.connection.active("opencode")) !== undefined
     yield* ctx.catalog.transform((catalog) => {
       for (const [providerID, item] of Object.entries(providers ?? {})) {
         catalog.provider.update(providerID, (provider) => {
@@ -176,11 +177,13 @@ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | S
       }
     })
 
+    const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
     yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe(
       Stream.filter((event) => event.data.integrationID === Integration.ID.make("opencode")),
-      Stream.runForEach(() => load().pipe(Effect.andThen(ctx.catalog.reload()))),
+      Stream.runForEach(refresh),
       Effect.forkScoped({ startImmediately: true }),
     )
+    yield* refresh().pipe(Effect.forkScoped)
   }),
 })
 

+ 19 - 0
packages/core/src/process.ts

@@ -20,6 +20,7 @@ export class AppProcessError extends Schema.TaggedErrorClass<AppProcessError>()(
 }
 
 export interface RunOptions {
+  readonly combineOutput?: boolean
   readonly maxOutputBytes?: number
   readonly maxErrorBytes?: number
   readonly signal?: AbortSignal
@@ -37,8 +38,10 @@ export interface RunStreamOptions {
 export interface RunResult {
   readonly command: string
   readonly exitCode: number
+  readonly output?: Buffer
   readonly stdout: Buffer
   readonly stderr: Buffer
+  readonly outputTruncated?: boolean
   readonly stdoutTruncated: boolean
   readonly stderrTruncated: boolean
 }
@@ -143,6 +146,22 @@ export const layer = Layer.effect(
       const collect = Effect.scoped(
         Effect.gen(function* () {
           const handle = yield* spawner.spawn(command)
+          if (options?.combineOutput) {
+            const [output, exitCode] = yield* Effect.all(
+              [collectStream(handle.all, options.maxOutputBytes), handle.exitCode],
+              { concurrency: "unbounded" },
+            )
+            return {
+              command: description,
+              exitCode,
+              output: output.buffer,
+              stdout: Buffer.alloc(0),
+              stderr: Buffer.alloc(0),
+              outputTruncated: output.truncated,
+              stdoutTruncated: false,
+              stderrTruncated: false,
+            } satisfies RunResult
+          }
           const [stdout, stderr, exitCode] = yield* Effect.all(
             [
               collectStream(handle.stdout, options?.maxOutputBytes),

+ 5 - 1
packages/core/src/public-event-manifest.ts

@@ -1,3 +1,7 @@
 export * as PublicEventManifest from "./public-event-manifest"
 
-export { ServerDefinitions as Definitions } from "@opencode-ai/schema/event-manifest"
+import { Event } from "@opencode-ai/schema/event"
+import { EventManifest } from "@opencode-ai/schema/event-manifest"
+
+export const Definitions = EventManifest.ServerDefinitions
+export const Latest = Event.latest(Definitions)

+ 7 - 1
packages/core/src/session.ts

@@ -400,7 +400,13 @@ export const layer = Layer.effect(
         })
       }),
       switchModel: Effect.fn("V2Session.switchModel")(function* (input) {
-        yield* result.get(input.sessionID)
+        const session = yield* result.get(input.sessionID)
+        if (
+          session.model?.providerID === input.model.providerID &&
+          session.model.id === input.model.id &&
+          (session.model.variant ?? "default") === (input.model.variant ?? "default")
+        )
+          return
         yield* events.publish(SessionEvent.ModelSwitched, {
           sessionID: input.sessionID,
           messageID: SessionMessage.ID.create(),

+ 2 - 0
packages/core/src/session/message-updater.ts

@@ -349,6 +349,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
                 id: event.data.reasoningID,
                 text: "",
                 providerMetadata: event.data.providerMetadata,
+                time: { created: event.data.timestamp },
               }),
             ),
           )
@@ -365,6 +366,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
           const match = latestReasoning(draft, event.data.reasoningID)
           if (match) {
             match.text = event.data.text
+            match.time = { created: match.time?.created ?? event.data.timestamp, completed: event.data.timestamp }
             if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata
           }
         })

+ 2 - 1
packages/core/src/session/runner/llm.ts

@@ -335,7 +335,8 @@ export const layer = Layer.effect(
           if (stream._tag === "Success" && !publisher.hasProviderError())
             yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true))
           if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
-          if (settled._tag === "Failure") return yield* Effect.failCause(settled.cause)
+          if (settled._tag === "Failure" && Cause.hasInterrupts(settled.cause))
+            return yield* Effect.failCause(settled.cause)
           return { needsContinuation: !publisher.hasProviderError() && needsContinuation, step: currentStep }
         }),
       )

+ 1 - 1
packages/core/src/session/runner/model.ts

@@ -133,7 +133,7 @@ export const fromCatalogModel = (
   credential?: Credential.Value,
 ): Effect.Effect<Model, UnsupportedApiError> => {
   const resolved =
-    credential?.metadata === undefined
+    credential?.type !== "key" || credential.metadata === undefined
       ? model
       : produce(model, (draft) => {
           Object.assign(draft.request.body, credential.metadata)

+ 45 - 11
packages/core/src/tool/apply-patch.ts

@@ -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,
+  }
+}

+ 28 - 36
packages/core/src/tool/bash.ts

@@ -30,16 +30,15 @@ export const Input = Schema.Struct({
     }),
 })
 
+const StructuredOutput = Schema.Struct({
+  exit: Schema.Number.pipe(Schema.optional),
+  truncated: Schema.Boolean,
+  timeout: Schema.Boolean.pipe(Schema.optional),
+})
+
 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. */
+  ...StructuredOutput.fields,
   output: Schema.String,
-  truncated: Schema.Boolean,
-  stdoutTruncated: Schema.Boolean.pipe(Schema.optional),
-  stderrTruncated: Schema.Boolean.pipe(Schema.optional),
-  timedOut: Schema.Boolean.pipe(Schema.optional),
   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}` }))),
         }),

+ 22 - 6
packages/core/src/tool/edit.ts

@@ -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
               })
             },
           }),

+ 26 - 8
packages/core/src/tool/tool.ts

@@ -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"

+ 9 - 0
packages/core/src/v1/config/provider-options.ts

@@ -42,6 +42,15 @@ const openai: Lowerer = {
   },
   request(options) {
     const result = snake(options)
+    if (options.reasoningEffort !== undefined || options.reasoningSummary !== undefined) {
+      result.reasoning = {
+        ...(isRecord(result.reasoning) ? result.reasoning : {}),
+        ...(options.reasoningEffort !== undefined ? { effort: options.reasoningEffort } : {}),
+        ...(options.reasoningSummary !== undefined ? { summary: options.reasoningSummary } : {}),
+      }
+      delete result.reasoning_effort
+      delete result.reasoning_summary
+    }
     if (options.textVerbosity !== undefined) {
       result.text = { ...(isRecord(result.text) ? result.text : {}), verbosity: options.textVerbosity }
       delete result.text_verbosity

+ 2 - 2
packages/core/test/config/config.test.ts

@@ -601,9 +601,9 @@ describe("Config", () => {
               models: {
                 model: {
                   request: {
-                    body: { temperature: 0.3, reasoning_effort: "high", service_tier: "priority" },
+                    body: { temperature: 0.3, reasoning: { effort: "high" }, service_tier: "priority" },
                   },
-                  variants: [{ id: "high", body: { reasoning_effort: "high", reasoning_summary: "auto" } }],
+                  variants: [{ id: "high", body: { reasoning: { effort: "high", summary: "auto" } } }],
                 },
               },
             })

+ 5 - 3
packages/core/test/config/provider-options.test.ts

@@ -42,12 +42,14 @@ describe("ConfigProviderOptionsV1", () => {
     expect(
       lowerer.request({
         reasoningEffort: "high",
+        reasoningSummary: "auto",
+        reasoning: { encryptedContent: true },
         textVerbosity: "low",
         text: { outputFormat: "plain" },
         nestedValue: { camelCase: true },
       }),
     ).toEqual({
-      reasoning_effort: "high",
+      reasoning: { encrypted_content: true, effort: "high", summary: "auto" },
       text: { output_format: "plain", verbosity: "low" },
       nested_value: { camel_case: true },
     })
@@ -138,8 +140,8 @@ describe("ConfigProviderOptionsV1", () => {
       body: { trace: true },
       settings: { resourceName: "resource" },
     })
-    expect(lowerer.request({ reasoningEffort: "high", textVerbosity: "low" })).toEqual({
-      reasoning_effort: "high",
+    expect(lowerer.request({ reasoningEffort: "high", reasoningSummary: "auto", textVerbosity: "low" })).toEqual({
+      reasoning: { effort: "high", summary: "auto" },
       text: { verbosity: "low" },
     })
   })

+ 27 - 3
packages/core/test/plugin/provider-opencode.test.ts

@@ -25,6 +25,20 @@ function required<T>(value: T | undefined): T {
   return value
 }
 
+function eventually<A>(
+  effect: Effect.Effect<A>,
+  predicate: (value: A) => boolean,
+  remaining = 1000,
+): Effect.Effect<A, Error> {
+  return Effect.gen(function* () {
+    const value = yield* effect
+    if (predicate(value)) return value
+    if (remaining === 0) return yield* Effect.fail(new Error("Timed out waiting for value"))
+    yield* Effect.promise(() => Bun.sleep(1))
+    return yield* eventually(effect, predicate, remaining - 1)
+  })
+}
+
 function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
   return Effect.acquireUseRelease(
     Effect.sync(() => {
@@ -67,11 +81,14 @@ describe("OpencodePlugin", () => {
     Effect.acquireUseRelease(
       Effect.sync(() => {
         const authorization: Array<string | null> = []
+        const gate = Promise.withResolvers<void>()
         return {
           authorization,
+          release: gate.resolve,
           server: Bun.serve({
             port: 0,
-            fetch: (request) => {
+            fetch: async (request) => {
+              await gate.promise
               authorization.push(request.headers.get("authorization"))
               const origin = new URL(request.url).origin
               return Response.json({
@@ -110,7 +127,7 @@ describe("OpencodePlugin", () => {
           }),
         }
       }),
-      ({ authorization, server }) =>
+      ({ authorization, release, server }) =>
         Effect.gen(function* () {
           const credentials = yield* Credential.Service
           const catalog = yield* Catalog.Service
@@ -128,8 +145,15 @@ describe("OpencodePlugin", () => {
           })
 
           yield* addPlugin()
+          expect(authorization).toEqual([])
+          release()
 
-          const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("remote")))
+          const provider = required(
+            yield* eventually(
+              catalog.provider.get(ProviderV2.ID.make("remote")),
+              (item) => item?.integrationID === Integration.ID.make("opencode"),
+            ),
+          )
           expect(provider).toMatchObject({
             name: "Remote",
             integrationID: "opencode",

+ 16 - 0
packages/core/test/process/process.test.ts

@@ -39,6 +39,22 @@ describe("AppProcess", () => {
       }),
     )
 
+    it.effect(
+      "captures stdout and stderr in emission order",
+      Effect.gen(function* () {
+        const svc = yield* AppProcess.Service
+        const script = [
+          'process.stdout.write("out 1\\n")',
+          'setTimeout(() => process.stderr.write("err 1\\n"), 10)',
+          'setTimeout(() => process.stdout.write("out 2\\n"), 20)',
+        ].join(";")
+        const result = yield* svc.run(cmd("-e", script), { combineOutput: true })
+        expect(result.output?.toString("utf8")).toBe("out 1\nerr 1\nout 2\n")
+        expect(result.stdout.toString("utf8")).toBe("")
+        expect(result.stderr.toString("utf8")).toBe("")
+      }),
+    )
+
     it.effect(
       "non-zero exit returns RunResult; caller can require success",
       Effect.gen(function* () {

+ 20 - 2
packages/core/test/session-create.test.ts

@@ -377,7 +377,7 @@ describe("SessionV2.create", () => {
     }),
   )
 
-  it.effect("persists repeated switches as distinct durable Session events", () =>
+  it.effect("ignores a model switch when the selected model is unchanged", () =>
     Effect.gen(function* () {
       const session = yield* SessionV2.Service
       const created = yield* session.create({ location })
@@ -389,11 +389,29 @@ describe("SessionV2.create", () => {
       const { db } = yield* Database.Service
       expect(
         yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, created.id)).all().pipe(Effect.orDie),
-      ).toHaveLength(3)
+      ).toHaveLength(2)
       expect(yield* session.get(created.id)).toMatchObject({ model })
     }),
   )
 
+  it.effect("treats an omitted variant as the default variant", () =>
+    Effect.gen(function* () {
+      const session = yield* SessionV2.Service
+      const model = ModelV2.Ref.make({ id: ModelV2.ID.make("sonnet"), providerID: ProviderV2.ID.anthropic })
+      const created = yield* session.create({ location, model })
+
+      yield* session.switchModel({
+        sessionID: created.id,
+        model: ModelV2.Ref.make({ ...model, variant: ModelV2.VariantID.make("default") }),
+      })
+
+      const { db } = yield* Database.Service
+      expect(
+        yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, created.id)).all().pipe(Effect.orDie),
+      ).toHaveLength(1)
+    }),
+  )
+
   it.effect("rejects a model switch for a missing Session", () =>
     Effect.gen(function* () {
       const session = yield* SessionV2.Service

+ 22 - 0
packages/core/test/session-runner-model.test.ts

@@ -4,6 +4,7 @@ import { LLMClient } from "@opencode-ai/llm/route"
 import { DateTime, Effect } from "effect"
 import { Headers } from "effect/unstable/http"
 import { Credential } from "@opencode-ai/core/credential"
+import { Integration } from "@opencode-ai/core/integration"
 import { ModelV2 } from "@opencode-ai/core/model"
 import { ProviderV2 } from "@opencode-ai/core/provider"
 import { ProjectV2 } from "@opencode-ai/core/project"
@@ -291,6 +292,27 @@ describe("SessionRunnerModel", () => {
     }),
   )
 
+  it.effect("does not project OAuth account metadata into the request body", () =>
+    Effect.gen(function* () {
+      const resolved = yield* SessionRunnerModel.fromCatalogModel(
+        ModelV2.Info.make({
+          ...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
+          request: { headers: {}, body: {} },
+        }),
+        Credential.OAuth.make({
+          type: "oauth",
+          methodID: Integration.MethodID.make("device"),
+          access: "secret",
+          refresh: "refresh",
+          expires: Date.now() + 60_000,
+          metadata: { server: "https://console.example", orgID: "org_123" },
+        }),
+      )
+
+      expect(resolved.route.defaults.http?.body).toEqual({})
+    }),
+  )
+
   it.effect("rejects catalog APIs without a native route", () =>
     Effect.gen(function* () {
       const failure = yield* SessionRunnerModel.fromCatalogModel(

+ 13 - 3
packages/core/test/session-runner.test.ts

@@ -2565,7 +2565,7 @@ describe("SessionRunnerLLM", () => {
     }),
   )
 
-  it.effect("propagates unexpected local tool defects operationally", () =>
+  it.effect("returns unexpected local tool defects to the model and continues", () =>
     Effect.gen(function* () {
       yield* setup
       const session = yield* SessionV2.Service
@@ -2579,11 +2579,20 @@ describe("SessionRunnerLLM", () => {
           LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
           LLMEvent.finish({ reason: "tool-calls" }),
         ],
+        [
+          LLMEvent.stepStart({ index: 0 }),
+          LLMEvent.textStart({ id: "text-after-defect" }),
+          LLMEvent.textDelta({ id: "text-after-defect", text: "Recovered" }),
+          LLMEvent.textEnd({ id: "text-after-defect" }),
+          LLMEvent.stepFinish({ index: 0, reason: "stop" }),
+          LLMEvent.finish({ reason: "stop" }),
+        ],
       ]
 
-      expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe("unexpected tool defect")
+      yield* session.resume(sessionID)
 
-      expect(requests).toHaveLength(1)
+      expect(requests).toHaveLength(2)
+      expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"])
       expect(yield* session.context(sessionID)).toMatchObject([
         { type: "user", text: "Call defect" },
         {
@@ -2599,6 +2608,7 @@ describe("SessionRunnerLLM", () => {
             },
           ],
         },
+        { type: "assistant", finish: "stop", content: [{ type: "text", text: "Recovered" }] },
       ])
     }),
   )

+ 23 - 0
packages/core/test/tool-apply-patch.test.ts

@@ -149,6 +149,29 @@ describe("ApplyPatchTool", () => {
                     { type: "update", resource: "update.txt" },
                     { type: "delete", resource: "remove.txt" },
                   ],
+                  files: [
+                    {
+                      file: "nested/new.txt",
+                      status: "added",
+                      additions: 1,
+                      deletions: 0,
+                      patch: expect.stringContaining("+created"),
+                    },
+                    {
+                      file: "update.txt",
+                      status: "modified",
+                      additions: 1,
+                      deletions: 1,
+                      patch: expect.stringContaining("-before\n+after"),
+                    },
+                    {
+                      file: "remove.txt",
+                      status: "deleted",
+                      additions: 0,
+                      deletions: 1,
+                      patch: expect.stringContaining("-remove"),
+                    },
+                  ],
                 })
                 expect(assertions).toMatchObject([
                   { sessionID, action: "edit", resources: ["nested/new.txt", "update.txt", "remove.txt"], save: ["*"] },

+ 47 - 31
packages/core/test/tool-bash.test.ts

@@ -31,8 +31,10 @@ let denyAction: string | undefined
 let result: AppProcess.RunResult = {
   command: "mock",
   exitCode: 0,
+  output: Buffer.from("hello\n"),
   stdout: Buffer.from("hello\n"),
   stderr: Buffer.alloc(0),
+  outputTruncated: false,
   stdoutTruncated: false,
   stderrTruncated: false,
 }
@@ -83,8 +85,10 @@ const reset = () => {
   result = {
     command: "mock",
     exitCode: 0,
+    output: Buffer.from("hello\n"),
     stdout: Buffer.from("hello\n"),
     stderr: Buffer.alloc(0),
+    outputTruncated: false,
     stdoutTruncated: false,
     stderrTruncated: false,
   }
@@ -135,24 +139,33 @@ describe("BashTool", () => {
             expect(definitions.map((tool) => tool.name)).toEqual(["bash"])
             expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.background")
             expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.description")
+            expect(definitions[0]?.outputSchema).not.toHaveProperty("properties.output")
+            expect(definitions[0]?.outputSchema).not.toHaveProperty("properties.command")
+            expect(definitions[0]?.outputSchema).not.toHaveProperty("properties.cwd")
             expect(yield* toolDefinitions(registry, [{ action: "bash", resource: "*", effect: "deny" }])).toEqual([])
             expect(yield* settleTool(registry, call({ command: "pwd" }))).toEqual({
-              result: { type: "text", value: "hello\n\n\nCommand exited with code 0." },
+              result: {
+                type: "content",
+                value: [
+                  { type: "text", text: "hello\n" },
+                  { type: "text", text: "Command exited with code 0." },
+                ],
+              },
               output: {
                 structured: {
-                  command: "pwd",
-                  cwd: realpathSync(tmp.path),
-                  exitCode: 0,
-                  output: "hello\n",
+                  exit: 0,
                   truncated: false,
                 },
-                content: [{ type: "text", text: "hello\n\n\nCommand exited with code 0." }],
+                content: [
+                  { type: "text", text: "hello\n" },
+                  { type: "text", text: "Command exited with code 0." },
+                ],
               },
             })
             expect(runs).toMatchObject([{ command: "pwd", cwd: realpathSync(tmp.path) }])
             expect(runs[0]?.options).toMatchObject({
+              combineOutput: true,
               maxOutputBytes: BashTool.MAX_CAPTURE_BYTES,
-              maxErrorBytes: BashTool.MAX_CAPTURE_BYTES,
             })
             expect(assertions).toMatchObject([{ sessionID, action: "bash", resources: ["pwd"], save: ["pwd"] }])
           }),
@@ -222,13 +235,17 @@ describe("BashTool", () => {
           ).pipe(
             Effect.andThen((settled) =>
               Effect.sync(() => {
-                expect(settled.result).toEqual({ type: "text", value: "core-bash\n\nCommand exited with code 0." })
+                expect(settled.result).toEqual({
+                  type: "content",
+                  value: [
+                    { type: "text", text: "core-bash" },
+                    { type: "text", text: "Command exited with code 0." },
+                  ],
+                })
                 expect(settled.output?.structured).toMatchObject({
-                  command: "printf core-bash",
-                  cwd: realpathSync(tmp.path),
-                  exitCode: 0,
-                  output: "core-bash",
+                  exit: 0,
                 })
+                expect(settled.output?.structured).not.toHaveProperty("output")
               }),
             ),
           )
@@ -303,11 +320,13 @@ describe("BashTool", () => {
               expect(assertions.map((item) => item.action)).toEqual(["bash"])
               expect(runs).toHaveLength(1)
               expect(settled.output?.structured).toMatchObject({
-                warnings: [
-                  `Command argument references external directory ${path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")}. Bash runs with host-user filesystem, process, and network authority; this scan is advisory only.`,
-                ],
+                truncated: false,
+              })
+              expect(settled.output?.structured).not.toHaveProperty("warnings")
+              expect(settled.output?.content[1]).toMatchObject({
+                type: "text",
+                text: expect.stringContaining("Warnings:"),
               })
-              expect(settled.result).toMatchObject({ type: "text", value: expect.stringContaining("Warnings:") })
             }),
           ),
         )
@@ -324,21 +343,19 @@ describe("BashTool", () => {
       Effect.promise(() => tmpdir()),
       (tmp) => {
         reset()
-        result = { ...result, exitCode: 7, stdout: Buffer.from("HEAD full output TAIL") }
+        result = { ...result, exitCode: 7, output: Buffer.from("HEAD full output TAIL") }
         return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "false" }, "call-overflow"))).pipe(
           Effect.andThen((settled) =>
             Effect.sync(() => {
-              expect(settled.result).toMatchObject({
+              expect(settled.output?.content[1]).toMatchObject({
                 type: "text",
-                value: expect.stringContaining("Command exited with code 7"),
+                text: expect.stringContaining("Command exited with code 7"),
               })
               expect(settled.output?.structured).toMatchObject({
-                command: "false",
-                cwd: realpathSync(tmp.path),
-                exitCode: 7,
-                output: "HEAD full output TAIL",
+                exit: 7,
                 truncated: false,
               })
+              expect(settled.output?.content[0]).toEqual({ type: "text", text: "HEAD full output TAIL" })
             }),
           ),
         )
@@ -352,14 +369,14 @@ describe("BashTool", () => {
       Effect.promise(() => tmpdir()),
       (tmp) => {
         reset()
-        result = { ...result, stdoutTruncated: true }
+        result = { ...result, outputTruncated: true }
         return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "verbose" }))).pipe(
           Effect.andThen((settled) =>
             Effect.sync(() => {
-              expect(settled.output?.structured).toMatchObject({ truncated: true, stdoutTruncated: true })
-              expect(settled.result).toMatchObject({
+              expect(settled.output?.structured).toMatchObject({ truncated: true })
+              expect(settled.output?.content[0]).toMatchObject({
                 type: "text",
-                value: expect.stringContaining("stdout capture truncated"),
+                text: expect.stringContaining("output capture truncated"),
               })
               expect(settled.output?.structured).not.toHaveProperty("resource")
             }),
@@ -379,13 +396,12 @@ describe("BashTool", () => {
         return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "sleep 60", timeout: 10 }))).pipe(
           Effect.andThen((settled) =>
             Effect.sync(() => {
-              expect(settled.result).toMatchObject({
+              expect(settled.output?.content[1]).toMatchObject({
                 type: "text",
-                value: expect.stringContaining("Command timed out"),
+                text: expect.stringContaining("Command timed out"),
               })
               expect(settled.output?.structured).toMatchObject({
-                command: "sleep 60",
-                timedOut: true,
+                timeout: true,
                 truncated: false,
               })
             }),

+ 9 - 4
packages/core/test/tool-edit.test.ts

@@ -125,11 +125,16 @@ describe("EditTool", () => {
                   value: "Edited file successfully: hello.txt\nReplacements: 1\n```diff\n-before\n+after\n```",
                 })
                 expect(settled.output?.structured).toEqual({
-                  operation: "write",
-                  target: yield* Effect.promise(() => fs.realpath(target)),
-                  resource: "hello.txt",
-                  existed: true,
                   replacements: 1,
+                  files: [
+                    {
+                      file: "hello.txt",
+                      status: "modified",
+                      additions: 1,
+                      deletions: 1,
+                      patch: expect.stringContaining("-before\n+after"),
+                    },
+                  ],
                 })
                 expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\nrest\n")
                 expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["hello.txt"], save: ["*"] }])

+ 4 - 0
packages/schema/src/session-message.ts

@@ -150,6 +150,10 @@ export const AssistantReasoning = Schema.Struct({
   id: Schema.String,
   text: Schema.String,
   providerMetadata: ProviderMetadata.pipe(optional),
+  time: Schema.Struct({
+    created: DateTimeUtcFromMillis,
+    completed: DateTimeUtcFromMillis.pipe(optional),
+  }).pipe(optional),
 }).annotate({ identifier: "Session.Message.Assistant.Reasoning" })
 
 export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe(

+ 4 - 0
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -4030,6 +4030,10 @@ export type SessionMessageAssistantReasoning = {
   id: string
   text: string
   providerMetadata?: LlmProviderMetadata
+  time?: {
+    created: number
+    completed?: number
+  }
 }
 
 export type SessionMessageToolStatePending = {