Selaa lähdekoodia

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 2 kuukautta sitten
vanhempi
commit
93159bccbf

+ 1 - 0
bun.lock

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

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

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

+ 1 - 0
packages/core/package.json

@@ -104,6 +104,7 @@
     "ai-gateway-provider": "3.1.2",
     "ai-gateway-provider": "3.1.2",
     "bun-pty": "0.4.8",
     "bun-pty": "0.4.8",
     "cross-spawn": "catalog:",
     "cross-spawn": "catalog:",
+    "diff": "catalog:",
     "drizzle-orm": "catalog:",
     "drizzle-orm": "catalog:",
     "effect": "catalog:",
     "effect": "catalog:",
     "fuzzysort": "3.1.0",
     "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 { PluginV2 } from "../plugin"
 import { Reference } from "../reference"
 import { Reference } from "../reference"
 import { SkillV2 } from "../skill"
 import { SkillV2 } from "../skill"
+import { State } from "../state"
 import { FetchHttpClient, HttpClient } from "effect/unstable/http"
 import { FetchHttpClient, HttpClient } from "effect/unstable/http"
 import { AgentPlugin } from "./agent"
 import { AgentPlugin } from "./agent"
 import { CommandPlugin } from "./command"
 import { CommandPlugin } from "./command"
@@ -104,20 +105,22 @@ const layer = Layer.effectDiscard(
       return plugin.add(PluginV2.ID.make(loaded.id), loaded.effect)
       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 { Scope } from "effect"
 import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
 import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
 import { define } from "@opencode-ai/plugin/v2/effect/plugin"
 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) {
   effect: Effect.fn(function* (ctx) {
     const events = yield* EventV2.Service
     const events = yield* EventV2.Service
     const http = yield* HttpClient.HttpClient
     const http = yield* HttpClient.HttpClient
+    const loading = Semaphore.makeUnsafe(1)
     let connected = false
     let connected = false
     let providers: typeof ConfigV1.Info.Type.provider | undefined
     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)" } })
       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) => {
     yield* ctx.catalog.transform((catalog) => {
       for (const [providerID, item] of Object.entries(providers ?? {})) {
       for (const [providerID, item] of Object.entries(providers ?? {})) {
         catalog.provider.update(providerID, (provider) => {
         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(
     yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe(
       Stream.filter((event) => event.data.integrationID === Integration.ID.make("opencode")),
       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 }),
       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 {
 export interface RunOptions {
+  readonly combineOutput?: boolean
   readonly maxOutputBytes?: number
   readonly maxOutputBytes?: number
   readonly maxErrorBytes?: number
   readonly maxErrorBytes?: number
   readonly signal?: AbortSignal
   readonly signal?: AbortSignal
@@ -37,8 +38,10 @@ export interface RunStreamOptions {
 export interface RunResult {
 export interface RunResult {
   readonly command: string
   readonly command: string
   readonly exitCode: number
   readonly exitCode: number
+  readonly output?: Buffer
   readonly stdout: Buffer
   readonly stdout: Buffer
   readonly stderr: Buffer
   readonly stderr: Buffer
+  readonly outputTruncated?: boolean
   readonly stdoutTruncated: boolean
   readonly stdoutTruncated: boolean
   readonly stderrTruncated: boolean
   readonly stderrTruncated: boolean
 }
 }
@@ -143,6 +146,22 @@ export const layer = Layer.effect(
       const collect = Effect.scoped(
       const collect = Effect.scoped(
         Effect.gen(function* () {
         Effect.gen(function* () {
           const handle = yield* spawner.spawn(command)
           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(
           const [stdout, stderr, exitCode] = yield* Effect.all(
             [
             [
               collectStream(handle.stdout, options?.maxOutputBytes),
               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 * 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) {
       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, {
         yield* events.publish(SessionEvent.ModelSwitched, {
           sessionID: input.sessionID,
           sessionID: input.sessionID,
           messageID: SessionMessage.ID.create(),
           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,
                 id: event.data.reasoningID,
                 text: "",
                 text: "",
                 providerMetadata: event.data.providerMetadata,
                 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)
           const match = latestReasoning(draft, event.data.reasoningID)
           if (match) {
           if (match) {
             match.text = event.data.text
             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
             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())
           if (stream._tag === "Success" && !publisher.hasProviderError())
             yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true))
             yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true))
           if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
           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 }
           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,
   credential?: Credential.Value,
 ): Effect.Effect<Model, UnsupportedApiError> => {
 ): Effect.Effect<Model, UnsupportedApiError> => {
   const resolved =
   const resolved =
-    credential?.metadata === undefined
+    credential?.type !== "key" || credential.metadata === undefined
       ? model
       ? model
       : produce(model, (draft) => {
       : produce(model, (draft) => {
           Object.assign(draft.request.body, credential.metadata)
           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"
 export * as ApplyPatchTool from "./apply-patch"
 
 
 import { ToolFailure } from "@opencode-ai/llm"
 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 { Effect, Layer, Schema } from "effect"
 import { FileMutation } from "../file-mutation"
 import { FileMutation } from "../file-mutation"
 import { FSUtil } from "../fs-util"
 import { FSUtil } from "../fs-util"
@@ -24,7 +26,10 @@ export const Applied = Schema.Struct({
   target: Schema.String,
   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 type Output = typeof Output.Type
 
 
 export const toModelOutput = (output: Output) =>
 export const toModelOutput = (output: Output) =>
@@ -36,11 +41,17 @@ export const toModelOutput = (output: Output) =>
   ].join("\n")
   ].join("\n")
 
 
 type Prepared =
 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" }> & {
   | (Extract<Patch.Hunk, { readonly type: "update" }> & {
       readonly target: LocationMutation.Target
       readonly target: LocationMutation.Target
       readonly source: Uint8Array
       readonly source: Uint8Array
       readonly content: string
       readonly content: string
+      readonly before: string
+      readonly after: string
     })
     })
 
 
 export const layer = Layer.effectDiscard(
 export const layer = Layer.effectDiscard(
@@ -113,29 +124,36 @@ export const layer = Layer.effectDiscard(
                 for (const { hunk, target } of targets) {
                 for (const { hunk, target } of targets) {
                   yield* Effect.gen(function* () {
                   yield* Effect.gen(function* () {
                     if (hunk.type === "add") {
                     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
                       return
                     }
                     }
                     if ((yield* fs.stat(target.canonical)).type !== "File") yield* fail(hunk.path)
                     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") {
                     if (hunk.type === "delete") {
-                      prepared.push({ ...hunk, target })
+                      prepared.push({ ...hunk, target, before, after: "" })
                       return
                       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({
                     prepared.push({
                       ...hunk,
                       ...hunk,
                       target,
                       target,
                       source,
                       source,
                       content: Patch.joinBom(update.content, update.bom),
                       content: Patch.joinBom(update.content, update.bom),
+                      before,
+                      after: update.content,
                     })
                     })
                   }).pipe(Effect.mapError(() => fail(hunk.path)))
                   }).pipe(Effect.mapError(() => fail(hunk.path)))
                 }
                 }
 
 
+                const patchFiles = prepared.map(patchFile)
                 yield* Effect.forEach(
                 yield* Effect.forEach(
                   prepared,
                   prepared,
                   (change) =>
                   (change) =>
@@ -165,7 +183,7 @@ export const layer = Layer.effectDiscard(
                     }).pipe(Effect.mapError(() => fail(change.path))),
                     }).pipe(Effect.mapError(() => fail(change.path))),
                   { discard: true },
                   { discard: true },
                 )
                 )
-                return { applied }
+                return { applied, files: patchFiles }
               }).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch"))))
               }).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch"))))
             },
             },
           }),
           }),
@@ -175,3 +193,19 @@ export const layer = Layer.effectDiscard(
       .pipe(Effect.orDie)
       .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({
 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,
   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),
   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 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 modelOutput = (output: Output) => {
   const warnings = output.warnings?.length
   const warnings = output.warnings?.length
     ? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}`
     ? `\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) =>
 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.`,
           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,
           input: Input,
           output: Output,
           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) =>
           execute: (input, context) =>
             Effect.gen(function* () {
             Effect.gen(function* () {
               const source = {
               const source = {
@@ -163,9 +159,9 @@ export const layer = Layer.effectDiscard(
               const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS
               const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS
               const result = yield* appProcess
               const result = yield* appProcess
                 .run(command, {
                 .run(command, {
+                  combineOutput: true,
                   timeout: Duration.millis(timeout),
                   timeout: Duration.millis(timeout),
                   maxOutputBytes: MAX_CAPTURE_BYTES,
                   maxOutputBytes: MAX_CAPTURE_BYTES,
-                  maxErrorBytes: MAX_CAPTURE_BYTES,
                 })
                 })
                 .pipe(
                 .pipe(
                   Effect.catchTag("AppProcessError", (error) =>
                   Effect.catchTag("AppProcessError", (error) =>
@@ -174,26 +170,22 @@ export const layer = Layer.effectDiscard(
                 )
                 )
               if (!result) {
               if (!result) {
                 return {
                 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.`,
                   output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
                   truncated: false,
                   truncated: false,
-                  timedOut: true,
+                  timeout: true,
                   ...(warnings.length ? { warnings } : {}),
                   ...(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 {
               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 } : {}),
                 ...(warnings.length ? { warnings } : {}),
-                ...(result.stdoutTruncated ? { stdoutTruncated: true } : {}),
-                ...(result.stderrTruncated ? { stderrTruncated: true } : {}),
               }
               }
             }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` }))),
             }).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"
 export * as EditTool from "./edit"
 
 
 import { ToolFailure } from "@opencode-ai/llm"
 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 { Effect, Layer, Schema } from "effect"
 import { FileMutation } from "../file-mutation"
 import { FileMutation } from "../file-mutation"
 import { FSUtil } from "../fs-util"
 import { FSUtil } from "../fs-util"
@@ -30,10 +32,7 @@ export const Input = Schema.Struct({
 })
 })
 
 
 export const Output = 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,
   replacements: Schema.Number,
 })
 })
 export type Output = typeof Output.Type
 export type Output = typeof Output.Type
@@ -71,7 +70,7 @@ const previewLines = (value: string, prefix: "+" | "-") => {
 
 
 export const toModelOutput = (output: Output, oldString: string, newString: string) =>
 export const toModelOutput = (output: Output, oldString: string, newString: string) =>
   [
   [
-    `Edited file successfully: ${output.resource}`,
+    `Edited file successfully: ${output.files[0]?.file}`,
     `Replacements: ${output.replacements}`,
     `Replacements: ${output.replacements}`,
     "```diff",
     "```diff",
     ...previewLines(oldString, "-"),
     ...previewLines(oldString, "-"),
@@ -179,6 +178,13 @@ export const layer = Layer.effectDiscard(
                   input.replaceAll === true
                   input.replaceAll === true
                     ? source.text.replaceAll(oldString, newString)
                     ? source.text.replaceAll(oldString, newString)
                     : source.text.replace(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 next = splitBom(replaced)
                 const result = yield* unableToEdit(
                 const result = yield* unableToEdit(
                   files.writeIfUnchanged({
                   files.writeIfUnchanged({
@@ -187,7 +193,17 @@ export const layer = Layer.effectDiscard(
                     content: joinBom(next.text, source.bom || next.bom),
                     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: "text"; readonly text: string }
   | { readonly type: "file"; readonly data: string; readonly mime: string; readonly name?: 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 description: string
   readonly input: Input
   readonly input: Input
   readonly output: Output
   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: (
   readonly execute: (
     input: Schema.Schema.Type<Input>,
     input: Schema.Schema.Type<Input>,
     context: Context,
     context: Context,
@@ -59,10 +68,12 @@ type Runtime = {
 
 
 const runtimes = new WeakMap<AnyTool, 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>()
   const definitions = new Map<string, ToolDefinition>()
   runtimes.set(tool, {
   runtimes.set(tool, {
     definition: (name) => {
     definition: (name) => {
@@ -72,7 +83,7 @@ export function make<Input extends SchemaType<any>, Output extends SchemaType<an
         name,
         name,
         description: config.description,
         description: config.description,
         inputSchema: toJsonSchema(config.input),
         inputSchema: toJsonSchema(config.input),
-        outputSchema: toJsonSchema(config.output),
+        outputSchema: toJsonSchema(config.structured ?? config.output),
       })
       })
       definitions.set(name, definition)
       definitions.set(name, definition)
       return definition
       return definition
@@ -84,6 +95,13 @@ export function make<Input extends SchemaType<any>, Output extends SchemaType<an
           config.execute(input, context).pipe(
           config.execute(input, context).pipe(
             Effect.flatMap((output) =>
             Effect.flatMap((output) =>
               Schema.encodeEffect(config.output)(output).pipe(
               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(
                 Effect.mapError(
                   (error) =>
                   (error) =>
                     new ToolFailure({
                     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:
               content:
                 config.toModelOutput?.({ input, output }).map((part) =>
                 config.toModelOutput?.({ input, output }).map((part) =>
                   part.type === "text"
                   part.type === "text"

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

@@ -42,6 +42,15 @@ const openai: Lowerer = {
   },
   },
   request(options) {
   request(options) {
     const result = snake(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) {
     if (options.textVerbosity !== undefined) {
       result.text = { ...(isRecord(result.text) ? result.text : {}), verbosity: options.textVerbosity }
       result.text = { ...(isRecord(result.text) ? result.text : {}), verbosity: options.textVerbosity }
       delete result.text_verbosity
       delete result.text_verbosity

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

@@ -601,9 +601,9 @@ describe("Config", () => {
               models: {
               models: {
                 model: {
                 model: {
                   request: {
                   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(
     expect(
       lowerer.request({
       lowerer.request({
         reasoningEffort: "high",
         reasoningEffort: "high",
+        reasoningSummary: "auto",
+        reasoning: { encryptedContent: true },
         textVerbosity: "low",
         textVerbosity: "low",
         text: { outputFormat: "plain" },
         text: { outputFormat: "plain" },
         nestedValue: { camelCase: true },
         nestedValue: { camelCase: true },
       }),
       }),
     ).toEqual({
     ).toEqual({
-      reasoning_effort: "high",
+      reasoning: { encrypted_content: true, effort: "high", summary: "auto" },
       text: { output_format: "plain", verbosity: "low" },
       text: { output_format: "plain", verbosity: "low" },
       nested_value: { camel_case: true },
       nested_value: { camel_case: true },
     })
     })
@@ -138,8 +140,8 @@ describe("ConfigProviderOptionsV1", () => {
       body: { trace: true },
       body: { trace: true },
       settings: { resourceName: "resource" },
       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" },
       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
   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>) {
 function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
   return Effect.acquireUseRelease(
   return Effect.acquireUseRelease(
     Effect.sync(() => {
     Effect.sync(() => {
@@ -67,11 +81,14 @@ describe("OpencodePlugin", () => {
     Effect.acquireUseRelease(
     Effect.acquireUseRelease(
       Effect.sync(() => {
       Effect.sync(() => {
         const authorization: Array<string | null> = []
         const authorization: Array<string | null> = []
+        const gate = Promise.withResolvers<void>()
         return {
         return {
           authorization,
           authorization,
+          release: gate.resolve,
           server: Bun.serve({
           server: Bun.serve({
             port: 0,
             port: 0,
-            fetch: (request) => {
+            fetch: async (request) => {
+              await gate.promise
               authorization.push(request.headers.get("authorization"))
               authorization.push(request.headers.get("authorization"))
               const origin = new URL(request.url).origin
               const origin = new URL(request.url).origin
               return Response.json({
               return Response.json({
@@ -110,7 +127,7 @@ describe("OpencodePlugin", () => {
           }),
           }),
         }
         }
       }),
       }),
-      ({ authorization, server }) =>
+      ({ authorization, release, server }) =>
         Effect.gen(function* () {
         Effect.gen(function* () {
           const credentials = yield* Credential.Service
           const credentials = yield* Credential.Service
           const catalog = yield* Catalog.Service
           const catalog = yield* Catalog.Service
@@ -128,8 +145,15 @@ describe("OpencodePlugin", () => {
           })
           })
 
 
           yield* addPlugin()
           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({
           expect(provider).toMatchObject({
             name: "Remote",
             name: "Remote",
             integrationID: "opencode",
             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(
     it.effect(
       "non-zero exit returns RunResult; caller can require success",
       "non-zero exit returns RunResult; caller can require success",
       Effect.gen(function* () {
       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* () {
     Effect.gen(function* () {
       const session = yield* SessionV2.Service
       const session = yield* SessionV2.Service
       const created = yield* session.create({ location })
       const created = yield* session.create({ location })
@@ -389,11 +389,29 @@ describe("SessionV2.create", () => {
       const { db } = yield* Database.Service
       const { db } = yield* Database.Service
       expect(
       expect(
         yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, created.id)).all().pipe(Effect.orDie),
         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 })
       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", () =>
   it.effect("rejects a model switch for a missing Session", () =>
     Effect.gen(function* () {
     Effect.gen(function* () {
       const session = yield* SessionV2.Service
       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 { DateTime, Effect } from "effect"
 import { Headers } from "effect/unstable/http"
 import { Headers } from "effect/unstable/http"
 import { Credential } from "@opencode-ai/core/credential"
 import { Credential } from "@opencode-ai/core/credential"
+import { Integration } from "@opencode-ai/core/integration"
 import { ModelV2 } from "@opencode-ai/core/model"
 import { ModelV2 } from "@opencode-ai/core/model"
 import { ProviderV2 } from "@opencode-ai/core/provider"
 import { ProviderV2 } from "@opencode-ai/core/provider"
 import { ProjectV2 } from "@opencode-ai/core/project"
 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", () =>
   it.effect("rejects catalog APIs without a native route", () =>
     Effect.gen(function* () {
     Effect.gen(function* () {
       const failure = yield* SessionRunnerModel.fromCatalogModel(
       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* () {
     Effect.gen(function* () {
       yield* setup
       yield* setup
       const session = yield* SessionV2.Service
       const session = yield* SessionV2.Service
@@ -2579,11 +2579,20 @@ describe("SessionRunnerLLM", () => {
           LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
           LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
           LLMEvent.finish({ 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([
       expect(yield* session.context(sessionID)).toMatchObject([
         { type: "user", text: "Call defect" },
         { 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: "update", resource: "update.txt" },
                     { type: "delete", resource: "remove.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([
                 expect(assertions).toMatchObject([
                   { sessionID, action: "edit", resources: ["nested/new.txt", "update.txt", "remove.txt"], save: ["*"] },
                   { 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 = {
 let result: AppProcess.RunResult = {
   command: "mock",
   command: "mock",
   exitCode: 0,
   exitCode: 0,
+  output: Buffer.from("hello\n"),
   stdout: Buffer.from("hello\n"),
   stdout: Buffer.from("hello\n"),
   stderr: Buffer.alloc(0),
   stderr: Buffer.alloc(0),
+  outputTruncated: false,
   stdoutTruncated: false,
   stdoutTruncated: false,
   stderrTruncated: false,
   stderrTruncated: false,
 }
 }
@@ -83,8 +85,10 @@ const reset = () => {
   result = {
   result = {
     command: "mock",
     command: "mock",
     exitCode: 0,
     exitCode: 0,
+    output: Buffer.from("hello\n"),
     stdout: Buffer.from("hello\n"),
     stdout: Buffer.from("hello\n"),
     stderr: Buffer.alloc(0),
     stderr: Buffer.alloc(0),
+    outputTruncated: false,
     stdoutTruncated: false,
     stdoutTruncated: false,
     stderrTruncated: false,
     stderrTruncated: false,
   }
   }
@@ -135,24 +139,33 @@ describe("BashTool", () => {
             expect(definitions.map((tool) => tool.name)).toEqual(["bash"])
             expect(definitions.map((tool) => tool.name)).toEqual(["bash"])
             expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.background")
             expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.background")
             expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.description")
             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* toolDefinitions(registry, [{ action: "bash", resource: "*", effect: "deny" }])).toEqual([])
             expect(yield* settleTool(registry, call({ command: "pwd" }))).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: {
               output: {
                 structured: {
                 structured: {
-                  command: "pwd",
-                  cwd: realpathSync(tmp.path),
-                  exitCode: 0,
-                  output: "hello\n",
+                  exit: 0,
                   truncated: false,
                   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).toMatchObject([{ command: "pwd", cwd: realpathSync(tmp.path) }])
             expect(runs[0]?.options).toMatchObject({
             expect(runs[0]?.options).toMatchObject({
+              combineOutput: true,
               maxOutputBytes: BashTool.MAX_CAPTURE_BYTES,
               maxOutputBytes: BashTool.MAX_CAPTURE_BYTES,
-              maxErrorBytes: BashTool.MAX_CAPTURE_BYTES,
             })
             })
             expect(assertions).toMatchObject([{ sessionID, action: "bash", resources: ["pwd"], save: ["pwd"] }])
             expect(assertions).toMatchObject([{ sessionID, action: "bash", resources: ["pwd"], save: ["pwd"] }])
           }),
           }),
@@ -222,13 +235,17 @@ describe("BashTool", () => {
           ).pipe(
           ).pipe(
             Effect.andThen((settled) =>
             Effect.andThen((settled) =>
               Effect.sync(() => {
               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({
                 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(assertions.map((item) => item.action)).toEqual(["bash"])
               expect(runs).toHaveLength(1)
               expect(runs).toHaveLength(1)
               expect(settled.output?.structured).toMatchObject({
               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()),
       Effect.promise(() => tmpdir()),
       (tmp) => {
       (tmp) => {
         reset()
         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(
         return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "false" }, "call-overflow"))).pipe(
           Effect.andThen((settled) =>
           Effect.andThen((settled) =>
             Effect.sync(() => {
             Effect.sync(() => {
-              expect(settled.result).toMatchObject({
+              expect(settled.output?.content[1]).toMatchObject({
                 type: "text",
                 type: "text",
-                value: expect.stringContaining("Command exited with code 7"),
+                text: expect.stringContaining("Command exited with code 7"),
               })
               })
               expect(settled.output?.structured).toMatchObject({
               expect(settled.output?.structured).toMatchObject({
-                command: "false",
-                cwd: realpathSync(tmp.path),
-                exitCode: 7,
-                output: "HEAD full output TAIL",
+                exit: 7,
                 truncated: false,
                 truncated: false,
               })
               })
+              expect(settled.output?.content[0]).toEqual({ type: "text", text: "HEAD full output TAIL" })
             }),
             }),
           ),
           ),
         )
         )
@@ -352,14 +369,14 @@ describe("BashTool", () => {
       Effect.promise(() => tmpdir()),
       Effect.promise(() => tmpdir()),
       (tmp) => {
       (tmp) => {
         reset()
         reset()
-        result = { ...result, stdoutTruncated: true }
+        result = { ...result, outputTruncated: true }
         return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "verbose" }))).pipe(
         return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "verbose" }))).pipe(
           Effect.andThen((settled) =>
           Effect.andThen((settled) =>
             Effect.sync(() => {
             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",
                 type: "text",
-                value: expect.stringContaining("stdout capture truncated"),
+                text: expect.stringContaining("output capture truncated"),
               })
               })
               expect(settled.output?.structured).not.toHaveProperty("resource")
               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(
         return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "sleep 60", timeout: 10 }))).pipe(
           Effect.andThen((settled) =>
           Effect.andThen((settled) =>
             Effect.sync(() => {
             Effect.sync(() => {
-              expect(settled.result).toMatchObject({
+              expect(settled.output?.content[1]).toMatchObject({
                 type: "text",
                 type: "text",
-                value: expect.stringContaining("Command timed out"),
+                text: expect.stringContaining("Command timed out"),
               })
               })
               expect(settled.output?.structured).toMatchObject({
               expect(settled.output?.structured).toMatchObject({
-                command: "sleep 60",
-                timedOut: true,
+                timeout: true,
                 truncated: false,
                 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```",
                   value: "Edited file successfully: hello.txt\nReplacements: 1\n```diff\n-before\n+after\n```",
                 })
                 })
                 expect(settled.output?.structured).toEqual({
                 expect(settled.output?.structured).toEqual({
-                  operation: "write",
-                  target: yield* Effect.promise(() => fs.realpath(target)),
-                  resource: "hello.txt",
-                  existed: true,
                   replacements: 1,
                   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(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\nrest\n")
                 expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["hello.txt"], save: ["*"] }])
                 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,
   id: Schema.String,
   text: Schema.String,
   text: Schema.String,
   providerMetadata: ProviderMetadata.pipe(optional),
   providerMetadata: ProviderMetadata.pipe(optional),
+  time: Schema.Struct({
+    created: DateTimeUtcFromMillis,
+    completed: DateTimeUtcFromMillis.pipe(optional),
+  }).pipe(optional),
 }).annotate({ identifier: "Session.Message.Assistant.Reasoning" })
 }).annotate({ identifier: "Session.Message.Assistant.Reasoning" })
 
 
 export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe(
 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
   id: string
   text: string
   text: string
   providerMetadata?: LlmProviderMetadata
   providerMetadata?: LlmProviderMetadata
+  time?: {
+    created: number
+    completed?: number
+  }
 }
 }
 
 
 export type SessionMessageToolStatePending = {
 export type SessionMessageToolStatePending = {