feat(core): port v2 runtime fixes onto dev

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

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

View file

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

View file

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

View file

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

View file

@ -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* State.batch(
yield* add(ConfigReferencePlugin.Plugin) Effect.gen(function* () {
yield* add(AgentPlugin.Plugin) yield* add(ConfigReferencePlugin.Plugin)
yield* add(CommandPlugin.Plugin) yield* add(AgentPlugin.Plugin)
yield* add(SkillPlugin.Plugin) yield* add(CommandPlugin.Plugin)
yield* add(ModelsDevPlugin) yield* add(SkillPlugin.Plugin)
yield* add(ConfigAgentPlugin.Plugin) yield* add(ModelsDevPlugin)
yield* add(ConfigCommandPlugin.Plugin) yield* add(ConfigAgentPlugin.Plugin)
yield* add(ConfigSkillPlugin.Plugin) yield* add(ConfigCommandPlugin.Plugin)
for (const item of ProviderPlugins) yield* add(item) yield* add(ConfigSkillPlugin.Plugin)
yield* add(ConfigExternalPlugin.Plugin) for (const item of ProviderPlugins) yield* add(item)
yield* add(ConfigProviderPlugin.Plugin) yield* add(ConfigExternalPlugin.Plugin)
yield* add(VariantPlugin.Plugin) yield* add(ConfigProviderPlugin.Plugin)
}).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true })) yield* add(VariantPlugin.Plugin)
}),
).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true }))
}), }),
) )

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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, original)
const update = Patch.derive(
hunk.path,
hunk.chunks,
new TextDecoder("utf-8", { ignoreBOM: true }).decode(source),
)
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,
}
}

View file

@ -30,16 +30,15 @@ export const Input = Schema.Struct({
}), }),
}) })
const Output = Schema.Struct({ const StructuredOutput = Schema.Struct({
command: Schema.String, exit: Schema.Number.pipe(Schema.optional),
cwd: Schema.String,
exitCode: Schema.Number.pipe(Schema.optional),
/** Bounded compact equivalent of stdout/stderr: stderr is labeled when present. */
output: Schema.String,
truncated: Schema.Boolean, truncated: Schema.Boolean,
stdoutTruncated: Schema.Boolean.pipe(Schema.optional), timeout: Schema.Boolean.pipe(Schema.optional),
stderrTruncated: Schema.Boolean.pipe(Schema.optional), })
timedOut: Schema.Boolean.pipe(Schema.optional),
const Output = Schema.Struct({
...StructuredOutput.fields,
output: Schema.String,
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.` if (output.timeout) return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command timed out before completion.`
return `${output.output}${warnings}\n\nCommand exited with code ${output.exitCode}.` 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 output = result.output?.toString("utf8") || "(no output)"
const notice = captureNotice(result.stdoutTruncated, result.stderrTruncated) const notice = result.outputTruncated
? "[output capture truncated at the in-memory safety limit]"
: undefined
return { return {
command: input.command, exit: result.exitCode,
cwd: target.canonical, output: notice ? `${output}\n\n${notice}` : output,
exitCode: result.exitCode, truncated: result.outputTruncated === true,
output: notice ? `${compact}\n\n${notice}` : compact,
truncated: result.stdoutTruncated || result.stderrTruncated,
...(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}` }))),
}), }),

View file

@ -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"), files: Schema.Array(FileDiff.Info),
target: Schema.String,
resource: Schema.String,
existed: Schema.Boolean,
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
}) })
}, },
}), }),

View file

@ -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>>( export function make<
config: Config<Input, Output>, Input extends SchemaType<any>,
): Definition<Input, Output> { Output extends SchemaType<any>,
const tool = Object.freeze({}) as Definition<Input, Output> 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) => ({ Effect.map(({ output, structured }) => ({
structured: output, structured,
content: content:
config.toModelOutput?.({ input, output }).map((part) => config.toModelOutput?.({ input, output }).map((part) =>
part.type === "text" part.type === "text"

View file

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

View file

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

View file

@ -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({ expect(lowerer.request({ reasoningEffort: "high", reasoningSummary: "auto", textVerbosity: "low" })).toEqual({
reasoning_effort: "high", reasoning: { effort: "high", summary: "auto" },
text: { verbosity: "low" }, text: { verbosity: "low" },
}) })
}) })

View file

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

View file

@ -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* () {

View file

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

View file

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

View file

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

View file

@ -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: ["*"] },

View file

@ -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", exit: 0,
cwd: realpathSync(tmp.path),
exitCode: 0,
output: "hello\n",
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({
expect(settled.output?.structured).toMatchObject({ type: "content",
command: "printf core-bash", value: [
cwd: realpathSync(tmp.path), { type: "text", text: "core-bash" },
exitCode: 0, { type: "text", text: "Command exited with code 0." },
output: "core-bash", ],
}) })
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(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: [ truncated: false,
`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.`, })
], 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", exit: 7,
cwd: realpathSync(tmp.path),
exitCode: 7,
output: "HEAD full output TAIL",
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.output?.structured).toMatchObject({ truncated: true })
expect(settled.result).toMatchObject({ 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", timeout: true,
timedOut: true,
truncated: false, truncated: false,
}) })
}), }),

View file

@ -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: ["*"] }])

View file

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

View file

@ -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 = {