diff --git a/bun.lock b/bun.lock index 84c4f9e5002..2bdc2813c41 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 da0b97a7c08..88f1f89b31d 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 62d279d311c..636385e9914 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 461d2297f1d..6e25e48a917 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 7faa107856a..f07d72e5f4a 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 ef7b6f78ab6..417413e93bc 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 99c208d9067..11b84f79054 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 13df855e105..ad2cea69897 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 a97afb58b6d..46118a89fe4 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 3e22bfe3c67..07d6e6f5f93 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 ffe6deea01a..5ce173d1502 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 78e5b0f40d5..05e8e59fbbe 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 f86cbdd69e5..19507310c24 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 9b12704a223..fb5af187069 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 eb70f2cb474..1d9a82e9522 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 9506a6a2b0d..6bf0bd9e1aa 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 3e23e20c708..836975937a7 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 3edd9ed0748..44a1c0c9db5 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 30757a042c5..45d423f9582 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 580ff0fbf7e..fe169361178 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 b05745eaa8f..81efeb76236 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 94669b24be9..49bbce95a38 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 4779de38dce..2cb286804c0 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 74eda3378ed..9b666f09170 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 9bbea5f0c3a..f15621ef582 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 d8f96a58036..f95cd1c923a 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 f32bb0582e6..58ff532063a 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 3ac6ea713de..8159d14591b 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 = {