Compare commits

...
15 changed files with 119 additions and 30 deletions
@@ -1,8 +1,10 @@
import { Effect } from "effect"
import { Effect, Option } from "effect"
import { AIError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputError } from "../../schema/index.js"
import { eventError, parseToolInput, type ToolAccumulator } from "../shared.js"
import { parse } from "./partial-json.js"
type StreamKey = string | number
const parsePartialInput = Option.liftThrowable(parse)
/**
* One pending streamed tool call. Providers emit the tool identity and JSON
@@ -57,12 +59,15 @@ const inputStart = (tool: PendingTool) =>
providerMetadata: tool.providerMetadata,
})
const inputDelta = (tool: PendingTool, text: string) =>
LLMEvent.toolInputDelta({
const inputDelta = (tool: PendingTool, text: string) => {
const input = parsePartialInput(tool.input)
return LLMEvent.toolInputDelta({
id: tool.id,
name: tool.name,
text,
...(Option.isSome(input) ? { input: input.value } : {}),
})
}
const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
const raw = inputOverride ?? tool.input
+2
View File
@@ -152,6 +152,8 @@ export const ToolInputDelta = Schema.Struct({
id: ToolCallID,
name: Schema.String,
text: Schema.String,
/** Best-effort parse of all input fragments received through this delta. */
input: Schema.optional(Schema.Unknown),
}).annotate({ identifier: "LLM.Event.ToolInputDelta" })
export type ToolInputDelta = Schema.Schema.Type<typeof ToolInputDelta>
@@ -585,12 +585,11 @@ describe("Anthropic Messages route", () => {
it.effect("infers empty-signature compatibility across Kimi providers", () =>
Effect.gen(function* () {
const coding = AnthropicMessages.route
.with({
provider: "kimi-for-coding",
endpoint: { baseURL: "https://compatible.test/v1/" },
auth: Auth.header("x-api-key", "test"),
})
const coding = AnthropicMessages.route.with({
provider: "kimi-for-coding",
endpoint: { baseURL: "https://compatible.test/v1/" },
auth: Auth.header("x-api-key", "test"),
})
const moonshot = AnthropicMessages.route
.with({
provider: "moonshotai",
@@ -1129,8 +1128,14 @@ describe("Anthropic Messages route", () => {
expect(response.events).toEqual([
{ type: "step-start", index: 0 },
{ type: "tool-input-start", id: "call_1", name: "lookup" },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"', input: {} },
{
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: ':"weather"}',
input: { query: "weather" },
},
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined },
{
type: "tool-call",
@@ -475,8 +475,14 @@ describe("Bedrock Converse route", () => {
])
const events = response.events.filter((event) => event.type === "tool-input-delta")
expect(events).toEqual([
{ type: "tool-input-delta", id: "tool_1", name: "lookup", text: '{"query"' },
{ type: "tool-input-delta", id: "tool_1", name: "lookup", text: ':"weather"}' },
{ type: "tool-input-delta", id: "tool_1", name: "lookup", text: '{"query"', input: {} },
{
type: "tool-input-delta",
id: "tool_1",
name: "lookup",
text: ':"weather"}',
input: { query: "weather" },
},
])
expect(response.events.at(-1)).toMatchObject({
type: "finish",
+16 -4
View File
@@ -1164,8 +1164,14 @@ describe("OpenAI Chat route", () => {
expect(response.events).toEqual([
{ type: "step-start", index: 0 },
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"', input: {} },
{
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: ':"weather"}',
input: { query: "weather" },
},
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined },
{
type: "tool-call",
@@ -1265,8 +1271,14 @@ describe("OpenAI Chat route", () => {
expect(response.events).toEqual([
{ type: "step-start", index: 0 },
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"', input: {} },
{
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: ':"weather"}',
input: { query: "weather" },
},
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined },
{
type: "tool-call",
@@ -2806,12 +2806,14 @@ describe("OpenAI Responses route", () => {
id: "call_1",
name: "lookup",
text: '{"query"',
input: {},
},
{
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: ':"weather"}',
input: { query: "weather" },
},
{
type: "tool-input-end",
+43 -2
View File
@@ -23,9 +23,11 @@ describe("ToolStream", () => {
expect(first.events).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup" },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"', input: {} },
])
expect(second.events).toEqual([
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}', input: { query: "weather" } },
])
expect(second.events).toEqual([{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }])
expect(finished).toEqual({
tools: {},
events: [
@@ -36,6 +38,45 @@ describe("ToolStream", () => {
}),
)
it.effect("exposes cumulative partial string values", () =>
Effect.gen(function* () {
const result = ToolStream.appendOrStart(
ADAPTER,
ToolStream.empty<number>(),
0,
{ id: "call_1", name: "lookup", text: '{"query":"wea' },
"missing tool",
)
if (ToolStream.isError(result)) return yield* result
expect(result.events.at(-1)).toEqual({
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: '{"query":"wea',
input: { query: "wea" },
})
}),
)
it.effect("omits partial input when the accumulated value cannot be parsed", () =>
Effect.gen(function* () {
const result = ToolStream.appendOrStart(
ADAPTER,
ToolStream.empty<number>(),
0,
{ id: "call_1", name: "lookup", text: "x" },
"missing tool",
)
if (ToolStream.isError(result)) return yield* result
expect(result.events).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup" },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: "x" },
])
}),
)
it.effect("keeps accumulated identity when later deltas contain empty strings", () =>
Effect.gen(function* () {
const first = ToolStream.appendOrStart(
+2 -2
View File
@@ -1,5 +1,5 @@
export type Policy = boolean | "notify"
export type Action = "none" | "upgrade"
export type Action = "none" | "notify" | "upgrade"
const maximumComponent = "9007199254740991"
const versionPattern =
@@ -12,7 +12,7 @@ export function action(current: string, latest: string, policy: Policy): Action
if (!currentVersion || !latestVersion || sameRelease(currentVersion, latestVersion)) return "none"
// Major upgrades are never installed automatically.
if (currentVersion.major !== latestVersion.major) return "none"
return "upgrade"
return policy === "notify" ? "notify" : "upgrade"
}
function parseReleaseVersion(input: string) {
+6 -2
View File
@@ -12,8 +12,12 @@ describe("updater", () => {
test("automatically updates patches and minors", () => {
expect(action("1.2.3", "1.2.4", true)).toBe("upgrade")
expect(action("1.2.3", "1.3.0", true)).toBe("upgrade")
expect(action("1.2.3", "1.2.4", "notify")).toBe("upgrade")
expect(action("1.2.3", "1.3.0", "notify")).toBe("upgrade")
})
test("reports patches and minors without automatically installing them", () => {
expect(action("1.2.3", "1.2.4", "notify")).toBe("notify")
expect(action("1.2.3", "1.3.0", "notify")).toBe("notify")
expect(action("1.2.3", "1.2.3", "notify")).toBe("none")
})
test("skips when autoupdate is disabled", () => {
+2
View File
@@ -162,6 +162,8 @@ export const layer = Layer.effect(
})
const next = action(OPENCODE_VERSION, version, policy)
if (next === "none") return yield* Effect.logInfo("update check done", { action: "up-to-date" })
if (next === "notify")
return yield* Effect.logInfo("OpenCode update available", { current: OPENCODE_VERSION, latest: version })
const detected = yield* method()
if (!detected) return yield* Effect.logWarning("automatic update skipped: installation method not found")
yield* upgrade(detected, version)
@@ -237,9 +237,8 @@ export default function PrivacyPolicy() {
</td>
<td>
<ul>
<li>Providing, Customizing and Improving the Services</li>
<li>Marketing the Services</li>
<li>Corresponding with You</li>
<li>Passing through to upstream provider to provide services</li>
<li>Not stored</li>
</ul>
</td>
<td>
@@ -1,3 +1,8 @@
import { Plugin, PluginContextProvider, usePlugin } from "@opencode-ai/plugin/tui"
import { ensureRuntimePluginSupport } from "@opentui/solid/runtime-plugin-support/configure"
ensureRuntimePluginSupport()
ensureRuntimePluginSupport({
additional: {
"@opencode-ai/plugin/tui": { Plugin, PluginContextProvider, usePlugin },
},
})
@@ -15,6 +15,9 @@ export default Plugin.define({
})
```
Import `@opencode-ai/plugin/tui` directly. OpenCode resolves this import at runtime, so local CLI plugins do not need an
absolute path to an OpenCode checkout.
## Context
`setup` receives configuration, app metadata, the current location, the OpenCode client, cached data, theme tokens, the
@@ -54,3 +54,6 @@ OpenCode also discovers JavaScript and TypeScript plugins from `plugins/tui` und
<global-config>/plugins/tui/status.ts
<project>/.opencode/plugins/tui/status.ts
```
Discovered plugins can import `@opencode-ai/plugin/tui` directly; OpenCode resolves the package at runtime. See
[Building CLI plugins](/build/plugins/cli) for examples.
+3 -3
View File
@@ -128,9 +128,9 @@ agents.
### Autoupdate
Control automatic updates from the global config. Set this to `false` to
disable updates. The current beta treats `true` and `"notify"` identically and
automatically installs compatible non-major updates; project-level values are
ignored.
disable updates, or `"notify"` to report available updates without installing
them. Set this to `true` to automatically install compatible non-major updates.
Project-level values are ignored.
```jsonc
{