From 93159bccbf32c63a9f4510780d940131c2b3438b Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 27 Jun 2026 00:05:29 -0400 Subject: [PATCH] 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. --- bun.lock | 1 + packages/client/src/generated/types.ts | 2 + packages/core/package.json | 1 + packages/core/src/plugin/internal.ts | 31 +++---- packages/core/src/plugin/provider/opencode.ts | 9 ++- packages/core/src/process.ts | 19 +++++ packages/core/src/public-event-manifest.ts | 6 +- packages/core/src/session.ts | 8 +- packages/core/src/session/message-updater.ts | 2 + packages/core/src/session/runner/llm.ts | 3 +- packages/core/src/session/runner/model.ts | 2 +- packages/core/src/tool/apply-patch.ts | 56 ++++++++++--- packages/core/src/tool/bash.ts | 66 +++++++-------- packages/core/src/tool/edit.ts | 28 +++++-- packages/core/src/tool/tool.ts | 34 ++++++-- .../core/src/v1/config/provider-options.ts | 9 +++ packages/core/test/config/config.test.ts | 4 +- .../core/test/config/provider-options.test.ts | 8 +- .../test/plugin/provider-opencode.test.ts | 30 ++++++- packages/core/test/process/process.test.ts | 16 ++++ packages/core/test/session-create.test.ts | 22 ++++- .../core/test/session-runner-model.test.ts | 22 +++++ packages/core/test/session-runner.test.ts | 16 +++- packages/core/test/tool-apply-patch.test.ts | 23 ++++++ packages/core/test/tool-bash.test.ts | 80 +++++++++++-------- packages/core/test/tool-edit.test.ts | 13 ++- packages/schema/src/session-message.ts | 4 + packages/sdk/js/src/v2/gen/types.gen.ts | 4 + 28 files changed, 387 insertions(+), 132 deletions(-) diff --git a/bun.lock b/bun.lock index 84c4f9e50..2bdc2813c 100644 --- a/bun.lock +++ b/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", diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index da0b97a7c..88f1f89b3 100644 --- a/packages/client/src/generated/types.ts +++ b/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" diff --git a/packages/core/package.json b/packages/core/package.json index 62d279d31..636385e99 100644 --- a/packages/core/package.json +++ b/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", diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 461d2297f..6e25e48a9 100644 --- a/packages/core/src/plugin/internal.ts +++ b/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 })) }), ) diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index 7faa10785..f07d72e5f 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/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 { for (const [providerID, item] of Object.entries(providers ?? {})) { catalog.provider.update(providerID, (provider) => { @@ -176,11 +177,13 @@ export const OpencodePlugin = define 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) }), }) diff --git a/packages/core/src/process.ts b/packages/core/src/process.ts index ef7b6f78a..417413e93 100644 --- a/packages/core/src/process.ts +++ b/packages/core/src/process.ts @@ -20,6 +20,7 @@ export class AppProcessError extends Schema.TaggedErrorClass()( } 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), diff --git a/packages/core/src/public-event-manifest.ts b/packages/core/src/public-event-manifest.ts index 99c208d90..11b84f790 100644 --- a/packages/core/src/public-event-manifest.ts +++ b/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) diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 13df855e1..ad2cea698 100644 --- a/packages/core/src/session.ts +++ b/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(), diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index a97afb58b..46118a89f 100644 --- a/packages/core/src/session/message-updater.ts +++ b/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 } }) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 3e22bfe3c..07d6e6f5f 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/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 } }), ) diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index ffe6deea0..5ce173d15 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -133,7 +133,7 @@ export const fromCatalogModel = ( credential?: Credential.Value, ): Effect.Effect => { const resolved = - credential?.metadata === undefined + credential?.type !== "key" || credential.metadata === undefined ? model : produce(model, (draft) => { Object.assign(draft.request.body, credential.metadata) diff --git a/packages/core/src/tool/apply-patch.ts b/packages/core/src/tool/apply-patch.ts index 78e5b0f40..05e8e59fb 100644 --- a/packages/core/src/tool/apply-patch.ts +++ b/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 & { readonly target: LocationMutation.Target }) + | (Extract & { + readonly target: LocationMutation.Target + readonly before: string + readonly after: string + }) | (Extract & { 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, + } +} diff --git a/packages/core/src/tool/bash.ts b/packages/core/src/tool/bash.ts index f86cbdd69..19507310c 100644 --- a/packages/core/src/tool/bash.ts +++ b/packages/core/src/tool/bash.ts @@ -30,16 +30,15 @@ export const Input = Schema.Struct({ }), }) -const Output = Schema.Struct({ - command: Schema.String, - cwd: Schema.String, - exitCode: Schema.Number.pipe(Schema.optional), - /** Bounded compact equivalent of stdout/stderr: stderr is labeled when present. */ - output: Schema.String, +const StructuredOutput = Schema.Struct({ + exit: Schema.Number.pipe(Schema.optional), truncated: Schema.Boolean, - stdoutTruncated: Schema.Boolean.pipe(Schema.optional), - stderrTruncated: Schema.Boolean.pipe(Schema.optional), - timedOut: Schema.Boolean.pipe(Schema.optional), + timeout: Schema.Boolean.pipe(Schema.optional), +}) + +const Output = Schema.Struct({ + ...StructuredOutput.fields, + output: Schema.String, warnings: Schema.Array(Schema.String).pipe(Schema.optional), }) @@ -47,24 +46,12 @@ type Output = typeof Output.Type const defaultShell = () => (process.platform === "win32" ? (process.env.COMSPEC ?? "cmd.exe") : "/bin/sh") -const compactOutput = (stdout: string, stderr: string) => { - const output = stdout && stderr ? `${stdout}\n\nstderr:\n${stderr}` : stderr ? `stderr:\n${stderr}` : stdout - return output || "(no output)" -} - -const captureNotice = (stdoutTruncated: boolean, stderrTruncated: boolean) => { - if (stdoutTruncated && stderrTruncated) return "[stdout and stderr capture truncated at the in-memory safety limit]" - if (stdoutTruncated) return "[stdout capture truncated at the in-memory safety limit]" - if (stderrTruncated) return "[stderr capture truncated at the in-memory safety limit]" - return undefined -} - const modelOutput = (output: Output) => { const warnings = output.warnings?.length ? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}` : "" - if (output.timedOut) return `${output.output}${warnings}\n\nCommand timed out before completion.` - return `${output.output}${warnings}\n\nCommand exited with code ${output.exitCode}.` + if (output.timeout) return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command timed out before completion.` + return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command exited with code ${output.exit}.` } const isTimeout = (error: AppProcess.AppProcessError) => @@ -116,7 +103,16 @@ export const layer = Layer.effectDiscard( description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows.`, input: Input, output: Output, - toModelOutput: ({ output }) => [{ type: "text", text: modelOutput(output) }], + structured: StructuredOutput, + toStructuredOutput: ({ output }) => ({ + truncated: output.truncated, + ...(output.exit === undefined ? {} : { exit: output.exit }), + ...(output.timeout === undefined ? {} : { timeout: output.timeout }), + }), + toModelOutput: ({ output }) => [ + { type: "text", text: output.output }, + { type: "text", text: modelOutput(output) }, + ], execute: (input, context) => Effect.gen(function* () { const source = { @@ -163,9 +159,9 @@ export const layer = Layer.effectDiscard( const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS const result = yield* appProcess .run(command, { + combineOutput: true, timeout: Duration.millis(timeout), maxOutputBytes: MAX_CAPTURE_BYTES, - maxErrorBytes: MAX_CAPTURE_BYTES, }) .pipe( Effect.catchTag("AppProcessError", (error) => @@ -174,26 +170,22 @@ export const layer = Layer.effectDiscard( ) if (!result) { return { - command: input.command, - cwd: target.canonical, output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`, truncated: false, - timedOut: true, + timeout: true, ...(warnings.length ? { warnings } : {}), } } - const compact = compactOutput(result.stdout.toString("utf8"), result.stderr.toString("utf8")) - const notice = captureNotice(result.stdoutTruncated, result.stderrTruncated) + const output = result.output?.toString("utf8") || "(no output)" + const notice = result.outputTruncated + ? "[output capture truncated at the in-memory safety limit]" + : undefined return { - command: input.command, - cwd: target.canonical, - exitCode: result.exitCode, - output: notice ? `${compact}\n\n${notice}` : compact, - truncated: result.stdoutTruncated || result.stderrTruncated, + exit: result.exitCode, + output: notice ? `${output}\n\n${notice}` : output, + truncated: result.outputTruncated === true, ...(warnings.length ? { warnings } : {}), - ...(result.stdoutTruncated ? { stdoutTruncated: true } : {}), - ...(result.stderrTruncated ? { stderrTruncated: true } : {}), } }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` }))), }), diff --git a/packages/core/src/tool/edit.ts b/packages/core/src/tool/edit.ts index 9b12704a2..fb5af1870 100644 --- a/packages/core/src/tool/edit.ts +++ b/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 }) }, }), diff --git a/packages/core/src/tool/tool.ts b/packages/core/src/tool/tool.ts index eb70f2cb4..1d9a82e95 100644 --- a/packages/core/src/tool/tool.ts +++ b/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, Output extends SchemaType> = { +type Config< + Input extends SchemaType, + Output extends SchemaType, + Structured extends SchemaType = Output, +> = { readonly description: string readonly input: Input readonly output: Output + readonly structured?: Structured + readonly toStructuredOutput?: (input: { + readonly input: Schema.Schema.Type + readonly output: Output["Encoded"] + }) => Schema.Schema.Type readonly execute: ( input: Schema.Schema.Type, context: Context, @@ -59,10 +68,12 @@ type Runtime = { const runtimes = new WeakMap() -export function make, Output extends SchemaType>( - config: Config, -): Definition { - const tool = Object.freeze({}) as Definition +export function make< + Input extends SchemaType, + Output extends SchemaType, + Structured extends SchemaType = Output, +>(config: Config): Definition { + const tool = Object.freeze({}) as Definition const definitions = new Map() runtimes.set(tool, { definition: (name) => { @@ -72,7 +83,7 @@ export function make, Output extends SchemaType, Output extends SchemaType 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, Output extends SchemaType ({ - structured: output, + Effect.map(({ output, structured }) => ({ + structured, content: config.toModelOutput?.({ input, output }).map((part) => part.type === "text" diff --git a/packages/core/src/v1/config/provider-options.ts b/packages/core/src/v1/config/provider-options.ts index 9506a6a2b..6bf0bd9e1 100644 --- a/packages/core/src/v1/config/provider-options.ts +++ b/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 diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 3e23e20c7..836975937 100644 --- a/packages/core/test/config/config.test.ts +++ b/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" } } }], }, }, }) diff --git a/packages/core/test/config/provider-options.test.ts b/packages/core/test/config/provider-options.test.ts index 3edd9ed07..44a1c0c9d 100644 --- a/packages/core/test/config/provider-options.test.ts +++ b/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" }, }) }) diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index 30757a042..45d423f95 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -25,6 +25,20 @@ function required(value: T | undefined): T { return value } +function eventually( + effect: Effect.Effect, + predicate: (value: A) => boolean, + remaining = 1000, +): Effect.Effect { + 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(vars: Record, effect: () => Effect.Effect) { return Effect.acquireUseRelease( Effect.sync(() => { @@ -67,11 +81,14 @@ describe("OpencodePlugin", () => { Effect.acquireUseRelease( Effect.sync(() => { const authorization: Array = [] + const gate = Promise.withResolvers() 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", diff --git a/packages/core/test/process/process.test.ts b/packages/core/test/process/process.test.ts index 580ff0fbf..fe1693611 100644 --- a/packages/core/test/process/process.test.ts +++ b/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* () { diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index b05745eaa..81efeb762 100644 --- a/packages/core/test/session-create.test.ts +++ b/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 diff --git a/packages/core/test/session-runner-model.test.ts b/packages/core/test/session-runner-model.test.ts index 94669b24b..49bbce95a 100644 --- a/packages/core/test/session-runner-model.test.ts +++ b/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( diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 4779de38d..2cb286804 100644 --- a/packages/core/test/session-runner.test.ts +++ b/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" }] }, ]) }), ) diff --git a/packages/core/test/tool-apply-patch.test.ts b/packages/core/test/tool-apply-patch.test.ts index 74eda3378..9b666f091 100644 --- a/packages/core/test/tool-apply-patch.test.ts +++ b/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: ["*"] }, diff --git a/packages/core/test/tool-bash.test.ts b/packages/core/test/tool-bash.test.ts index 9bbea5f0c..f15621ef5 100644 --- a/packages/core/test/tool-bash.test.ts +++ b/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.output?.structured).toMatchObject({ - command: "printf core-bash", - cwd: realpathSync(tmp.path), - exitCode: 0, - output: "core-bash", + 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({ + 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, }) }), diff --git a/packages/core/test/tool-edit.test.ts b/packages/core/test/tool-edit.test.ts index d8f96a580..f95cd1c92 100644 --- a/packages/core/test/tool-edit.test.ts +++ b/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: ["*"] }]) diff --git a/packages/schema/src/session-message.ts b/packages/schema/src/session-message.ts index f32bb0582..58ff53206 100644 --- a/packages/schema/src/session-message.ts +++ b/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( diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 3ac6ea713..8159d1459 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/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 = {