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:
parent
fab8ec4f54
commit
93159bccbf
28 changed files with 385 additions and 130 deletions
1
bun.lock
1
bun.lock
|
|
@ -317,6 +317,7 @@
|
||||||
"ai-gateway-provider": "3.1.2",
|
"ai-gateway-provider": "3.1.2",
|
||||||
"bun-pty": "0.4.8",
|
"bun-pty": "0.4.8",
|
||||||
"cross-spawn": "catalog:",
|
"cross-spawn": "catalog:",
|
||||||
|
"diff": "catalog:",
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 }))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}` }))),
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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" } } }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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" },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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* () {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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" }] },
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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: ["*"] },
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -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: ["*"] }])
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue