Compare commits

..
Author SHA1 Message Date
Brendonovich 0f2b10993d fix(core): return formatted diff from write tool 2026-10-08 03:46:10 +00:00
18 changed files with 108 additions and 338 deletions

No files matched your search

+13 -8
View File
@@ -1,5 +1,5 @@
import { Effect, Option } from "effect"
import { AIError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputError } from "../../schema/index.js"
import { AIError, LLMEvent, type ProviderMetadata, type ToolCall } from "../../schema/index.js"
import { eventError, parseToolInput, type ToolAccumulator } from "../shared.js"
import { parse } from "./partial-json.js"
@@ -79,6 +79,16 @@ const inputDelta = (tool: PendingTool, text: string): LLMEvent => {
const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
const raw = inputOverride ?? tool.input
return parseToolInput(route, tool.name, raw).pipe(
Effect.catch((error) =>
tool.providerExecuted
? Effect.fail(error)
: Effect.succeed(
Option.getOrElse(
Option.map(parsePartialInput(raw), (input) => input ?? {}),
() => ({}),
),
),
),
Effect.map(
(input): ToolCall =>
LLMEvent.toolCall({
@@ -90,15 +100,10 @@ const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
providerMetadata: tool.providerMetadata,
}),
),
Effect.catch((error) =>
tool.providerExecuted
? Effect.fail(error)
: Effect.succeed(LLMEvent.toolInputError({ id: tool.id, name: tool.name, namespace: tool.namespace, raw })),
),
)
}
const finishEvents = (tool: PendingTool, event: ToolCall | ToolInputError): ReadonlyArray<LLMEvent> => [
const finishEvents = (tool: PendingTool, event: ToolCall): ReadonlyArray<LLMEvent> => [
LLMEvent.toolInputEnd({
id: tool.id,
name: tool.name,
@@ -201,7 +206,7 @@ export const appendExisting = <K extends StreamKey>(
/**
* Finalize one pending tool call: parse the accumulated raw JSON, remove it
* from state, and reject malformed local arguments without repairing them.
* from state, and recover incomplete local arguments when needed.
* Missing keys are a no-op because some providers emit stop events for
* non-tool content blocks.
*/
@@ -1131,7 +1131,7 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("rejects incomplete tool input at finalization", () =>
it.effect("recovers incomplete tool input at finalization", () =>
Effect.gen(function* () {
const body = eventStreamBody(
["messageStart", { role: "assistant" }],
@@ -1148,11 +1148,10 @@ describe("Bedrock Converse route", () => {
)
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
expect(response.toolCalls).toEqual([])
expect(response.events.find((event) => event.type === "tool-input-error")).toMatchObject({
expect(response.events.find((event) => event.type === "tool-call")).toMatchObject({
id: "tool_1",
name: "lookup",
raw: '{"query":"partial',
input: { query: "partial" },
})
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "end_turn" })
}),
@@ -4507,7 +4507,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("rejects authoritative incomplete final function arguments", () =>
it.effect("recovers authoritative incomplete final function arguments", () =>
Effect.gen(function* () {
const body = sseEvents(
{
@@ -4533,17 +4533,17 @@ describe("OpenAI Responses route", () => {
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(response.toolCalls).toEqual([])
expect(response.events.find(LLMEvent.is.toolInputError)).toMatchObject({
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
id: "call_1",
name: "lookup",
raw: '{"query":"partial',
input: { query: "partial" },
})
expect(response.finishReason.normalized).toBe("tool-calls")
expect(response.events.some(LLMEvent.is.toolInputError)).toBeFalse()
}),
)
it.effect("rejects incomplete function arguments when output_item.added is absent", () =>
it.effect("recovers incomplete function arguments when output_item.added is absent", () =>
Effect.gen(function* () {
const body = sseEvents(
{
@@ -4560,11 +4560,10 @@ describe("OpenAI Responses route", () => {
)
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
expect(response.toolCalls).toEqual([])
expect(response.events.find(LLMEvent.is.toolInputError)).toMatchObject({
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
id: "call_1",
name: "lookup",
raw: '{"query":"partial',
input: { query: "partial" },
})
expect(response.finishReason.normalized).toBe("tool-calls")
}),
+8 -8
View File
@@ -126,7 +126,7 @@ describe("ToolStream", () => {
}),
)
it.effect("rejects incomplete final local input", () =>
it.effect("finalizes incomplete local input using the partial JSON parser", () =>
Effect.gen(function* () {
const tools = ToolStream.start(ToolStream.empty<string>(), "item_1", {
id: "call_1",
@@ -139,13 +139,13 @@ describe("ToolStream", () => {
tools: {},
events: [
{ type: "tool-input-end", id: "call_1", name: "lookup" },
{ type: "tool-input-error", id: "call_1", name: "lookup", raw: '{"query":"partial' },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "partial" } },
],
})
}),
)
it.effect("rejects malformed string escapes in final local input", () =>
it.effect("repairs malformed string escapes in final local input", () =>
Effect.gen(function* () {
const tools = ToolStream.start(ToolStream.empty<string>(), "item_1", {
id: "call_1",
@@ -156,12 +156,12 @@ describe("ToolStream", () => {
expect(finished.events).toEqual([
{ type: "tool-input-end", id: "call_1", name: "lookup" },
{ type: "tool-input-error", id: "call_1", name: "lookup", raw: '{"path":"A\\H","text":"first\tsecond"}' },
{ type: "tool-call", id: "call_1", name: "lookup", input: { path: "A\\H", text: "first\tsecond" } },
])
}),
)
it.effect("rejects invalid final local input instead of defaulting to an empty object", () =>
it.effect("defaults unrecoverable local input to an empty object", () =>
Effect.gen(function* () {
const tools = ToolStream.start(ToolStream.empty<string>(), "item_1", {
id: "call_1",
@@ -172,12 +172,12 @@ describe("ToolStream", () => {
expect(finished.events).toEqual([
{ type: "tool-input-end", id: "call_1", name: "lookup" },
{ type: "tool-input-error", id: "call_1", name: "lookup", raw: "invalid" },
{ type: "tool-call", id: "call_1", name: "lookup", input: {} },
])
}),
)
it.effect("rejects incomplete input without dropping valid parallel tool calls", () =>
it.effect("recovers incomplete input alongside valid parallel tool calls", () =>
Effect.gen(function* () {
const valid = ToolStream.start(ToolStream.empty<number>(), 0, {
id: "call_valid",
@@ -197,7 +197,7 @@ describe("ToolStream", () => {
{ type: "tool-input-end", id: "call_valid", name: "lookup" },
{ type: "tool-call", id: "call_valid", name: "lookup", input: { query: "weather" } },
{ type: "tool-input-end", id: "call_invalid", name: "lookup" },
{ type: "tool-input-error", id: "call_invalid", name: "lookup", raw: '{"query":"partial' },
{ type: "tool-call", id: "call_invalid", name: "lookup", input: { query: "partial" } },
],
})
}),
+2 -29
View File
@@ -1,9 +1,9 @@
import { expect, test, type Page } from "@playwright/test"
import { expectPath, holdRoute, NO_PROVIDER, project, REMOTE_SERVER, seed, sessionHref } from "../utils/app"
import { holdRoute, NO_PROVIDER, project, REMOTE_SERVER, seed, sessionHref } from "../utils/app"
import { mockOpenCodeServer } from "../utils/mock-server"
import { fixture, mockStressTimeline } from "../utils/session-fixture"
import { mockRemoteServer } from "../utils/workspace"
import { APP_READY_TIMEOUT, expectAppVisible } from "../utils/waits"
import { expectAppVisible } from "../utils/waits"
test.use({ serviceWorkers: "block" })
@@ -77,33 +77,6 @@ test("the session context menu renames, exports, and deletes a Home session", as
await expect(renamedRow).toBeHidden()
})
test("the Home shortcut focuses session search, and the Home button leaves focus alone", async ({ page }) => {
await mockStressTimeline(page)
await seed(page, {
projects: { local: [{ worktree: fixture.directory, expanded: true }] },
lastProject: { local: fixture.directory },
tabs: [fixture.sourceID],
})
await page.goto(sessionHref(fixture.sourceID))
const editor = page.locator('[data-component="composer-editor"]')
await expect(editor).toBeEditable({ timeout: APP_READY_TIMEOUT })
await editor.click()
await page.keyboard.press("ControlOrMeta+b")
const search = page.getByRole("textbox", { name: /Search sessions/ })
await expect(search).toBeFocused()
await page.keyboard.type("jump")
await expect(page.getByRole("option")).toHaveCount(1)
await expect(page.getByRole("option")).toContainText(fixture.expected.targetTitle)
await page.keyboard.press("ControlOrMeta+b")
await expectPath(page, sessionHref(fixture.sourceID))
await page.getByRole("button", { name: "Home", exact: true }).click()
await expect(search).toHaveValue("")
await expect(search).not.toBeFocused()
})
test("Home shows loaded sessions before the location request resolves", async ({ page }) => {
const location = await holdRoute(page, (url) => url.pathname === "/api/location")
await openHome(page)
+3 -14
View File
@@ -1,11 +1,10 @@
import { useCommand } from "@/shell/commands/command"
import { useLayout } from "@/shell/state/layout"
import { useLanguage } from "@/runtime/i18n/language"
import { serverName } from "@/runtime/server/registry"
import { displayName } from "@opencode/ui/project-avatar"
import { sessionLabel } from "@/session/title"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createMemo, onCleanup, onMount } from "solid-js"
import { createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { HomeController } from "../model"
import { homeSessionSearchKey, type HomeSessionRecord, type HomeSessionsController } from "./controller"
@@ -16,19 +15,12 @@ type HomeSessionSearchSource = Pick<HomeSessionsController, "data" | "session">
export function createHomeSessionSearchController(home: HomeController, sessions: HomeSessionSearchSource) {
const command = useCommand()
const language = useLanguage()
const layout = useLayout()
const [state, setState] = createStore<{
value: string
focused: boolean
highlighted: string
exact: HomeSessionRecord | undefined
lookingUp: boolean
}>({
const [state, setState] = createStore({
value: "",
focused: false,
highlighted: "",
exact: undefined,
exact: undefined as HomeSessionRecord | undefined,
lookingUp: false,
})
@@ -86,9 +78,6 @@ export function createHomeSessionSearchController(home: HomeController, sessions
}),
)
onCleanup(() => lookup++)
onMount(() => {
if (layout.home.searchFocus.take()) focus()
})
command.register("home.search", () => [
{
-14
View File
@@ -214,9 +214,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
sessionTabPreview: {},
})
// Opening Home with its shortcut focuses session search, so typing filters immediately.
let homeSearchFocus = false
// Names of other session-scoped stores, e.g. extension storage, so pruning drops them with the layout state.
const [scoped, setScoped, , scopedReady] = persisted(
Persist.global("layout.scoped"),
@@ -358,17 +355,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setSelection(selection: HomeProjectSelection) {
setStore("home", "selection", reconcile(selection))
},
searchFocus: {
request() {
homeSearchFocus = true
},
take() {
const requested = homeSearchFocus
homeSearchFocus = false
return requested
},
},
},
sessionState: {
/** Records a session-scoped store, so pruning a session's layout state drops it too. */
+1 -4
View File
@@ -452,10 +452,7 @@ export function Titlebar(props: { verticalTabs?: { mount?: HTMLElement } }) {
category: language.t("command.category.view"),
keybind: windows() ? "alt+home" : "mod+b",
hidden: true,
onSelect: () => {
if (layout.route().type !== "home") layout.home.searchFocus.request()
toggleHome()
},
onSelect: toggleHome,
},
])
+11 -11
View File
@@ -144,19 +144,19 @@ export const layer = (options?: Options) =>
const loadWellknownEntry = Effect.fnUntraced(function* (entry: WellKnown.Entry) {
const auth = entry.manifest.auth
if (!auth) return []
const credential = (yield* credentials.list(entry.integrationID)).at(-1)
const variables = auth && credential?.value.type === "key" ? { [auth.env]: credential.value.key } : undefined
// A failed refresh, including a missing credential, keeps the last resolved config so remote providers
// and allowlists do not disappear; only removing the source clears it.
const configs = yield* (
variables ? wellknown.resolve(entry, variables) : Effect.fail(new Error("No usable wellknown credential"))
).pipe(
Effect.catch((error) =>
Effect.logWarning("failed to load wellknown config", { source: entry.origin, error }).pipe(
Effect.andThen(wellknown.cached(entry.origin)),
if (!credential || credential.value.type !== "key") return []
const variables = { [auth.env]: credential.value.key }
const configs = yield* wellknown
.resolve(entry, variables)
.pipe(
Effect.catch(() =>
Effect.logWarning("failed to load wellknown config", { source: entry.origin }).pipe(
Effect.as([] as const),
),
),
),
)
)
return yield* Effect.forEach(configs, (config) =>
ConfigVariable.substitute({
type: "virtual",
@@ -395,7 +395,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
})
const failUnsettledTools = Effect.fn("SessionRunner.failUnsettledTools")(
(error: SessionError.Error, scope: "hosted" | "all" | "uncalled" = "all") => failTools(error, scope),
(error: SessionError.Error, scope: "hosted" | "all" = "all") => failTools(error, scope),
)
const publish = Effect.fnUntraced(function* (event: LLMEvent) {
+1 -7
View File
@@ -61,11 +61,6 @@ interface Input {
const TOOLS_INTERRUPTED = { type: "aborted", message: "Tool execution interrupted" } as const
const STEP_INTERRUPTED = { type: "aborted", message: "Step interrupted" } as const
const RESULT_MISSING = { type: "tool.result-missing", message: "Provider did not return a tool result" } as const
const INPUT_INCOMPLETE = {
type: "tool.input-incomplete",
message:
"Tool call arguments were not completed and were not executed. Re-issue the tool call with complete arguments.",
} as const
/** Captures Location-scoped dependencies without introducing another service or execution loop. */
export const make = Effect.gen(function* () {
@@ -221,11 +216,10 @@ export const make = Effect.gen(function* () {
if (toolFailure) yield* publisher.failUnsettledTools(toolFailure)
if (interrupted) yield* publisher.failAssistant(STEP_INTERRUPTED)
// Parsers may leave unfinished calls without an execution event.
// All local fibers have joined; only provider-hosted results can still be missing.
if (llmError || (Exit.isSuccess(stream) && !recorded.providerFailed)) {
const missing = yield* publisher.failUnsettledTools(RESULT_MISSING, "hosted")
if (missing && !llmError && !recorded.finish) yield* publisher.failAssistant(RESULT_MISSING)
yield* publisher.failUnsettledTools(INPUT_INCOMPLETE, "uncalled")
}
const record = publisher.record()
+13 -6
View File
@@ -8,6 +8,7 @@ export * as WriteTool from "./write.js"
import type { Context } from "@opencode/plugin/effect/plugin"
import { ToolFailure } from "@opencode/ai"
import { FileDiff } from "@opencode/schema/file-diff"
import { Effect, Schema } from "effect"
import { Bom } from "@opencode/util/bom"
import { Environment } from "../../environment/index.js"
@@ -32,6 +33,7 @@ export const Output = Schema.Struct({
target: Schema.String,
resource: Schema.String,
existed: Schema.Boolean,
files: Schema.Array(FileDiff.Info),
})
export type Output = typeof Output.Type
@@ -85,13 +87,18 @@ export const Plugin = {
source,
})
const result = yield* fileMutation.writeTextPreservingBom({ target, content: input.content })
const bom = (yield* FileMutation.readText(environment.files, target.absolute)).bom
if (yield* formatter.file(target.absolute)) {
yield* FileMutation.syncTextBom(environment.files, target.absolute, bom)
}
return result
const written = yield* FileMutation.readText(environment.files, target.absolute)
const formatted = (yield* formatter.file(target.absolute))
? yield* FileMutation.syncTextBom(environment.files, target.absolute, written.bom)
: written.text
return {
...result,
files: [
fileDiff(result.resource, current?.text ?? "", formatted, result.existed ? "modified" : "added"),
],
} satisfies Output
}).pipe(
Effect.map((output) => ({ output, content: toModelContent(output) })),
Effect.map((output) => ({ output, content: toModelContent(output), metadata: { files: output.files } })),
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
),
}),
+4 -33
View File
@@ -48,10 +48,7 @@ export interface Interface {
readonly refresh: () => Effect.Effect<boolean, Error>
readonly add: (origin: string) => Effect.Effect<Entry, Error>
readonly remove: (origin: string) => Effect.Effect<void>
/** Resolves and caches the configuration for a registered source. */
readonly resolve: (entry: Entry, variables: Readonly<Record<string, string>>) => Effect.Effect<Config[], Error>
/** Returns the last configuration resolved for an origin, kept until the source is removed. */
readonly cached: (origin: string) => Effect.Effect<Config[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/WellKnown") {}
@@ -100,9 +97,6 @@ const resolveEntry = Effect.fnUntraced(function* (entry: Entry, variables: Reado
const sourcesKey = "wellknown:sources"
const Sources = Schema.Array(Schema.String)
const manifestKey = (origin: string) => `wellknown:manifest:${origin}`
const configKey = (origin: string) => `wellknown:config:${origin}`
const Configs = Schema.Array(Config)
const layer = Layer.effect(
Service,
@@ -113,22 +107,7 @@ const layer = Layer.effect(
const cache = yield* Ref.make(new Map<string, Entry>())
const lock = Semaphore.makeUnsafe(1)
const loadEntry = Effect.fn("WellKnown.loadEntry")(function* (origin: string) {
const manifest = yield* inspect(origin).pipe(
Effect.provideService(HttpClient.HttpClient, http),
Effect.tap((manifest) => kv.set(manifestKey(origin), manifest)),
// An unreachable source keeps its last manifest so its remote config still loads after a restart.
Effect.catch((error) =>
kv
.get(manifestKey(origin))
.pipe(
Effect.flatMap((cached) =>
Schema.is(Manifest)(cached)
? Effect.logWarning("failed to load wellknown manifest", { origin, error }).pipe(Effect.as(cached))
: Effect.fail(error),
),
),
),
)
const manifest = yield* inspect(origin).pipe(Effect.provideService(HttpClient.HttpClient, http))
return { origin, integrationID: Integration.ID.make(origin), manifest }
})
@@ -188,8 +167,6 @@ const layer = Layer.effect(
sourcesKey,
origins.filter((item) => item !== origin),
)
yield* kv.remove(manifestKey(origin))
yield* kv.remove(configKey(origin))
yield* Ref.update(cache, (current) => {
const next = new Map(current)
next.delete(origin)
@@ -199,15 +176,9 @@ const layer = Layer.effect(
},
(effect, _value) => lock.withPermit(effect),
),
resolve: Effect.fn("WellKnown.resolveEntry")(function* (entry, variables) {
const configs = yield* resolveEntry(entry, variables).pipe(Effect.provideService(HttpClient.HttpClient, http))
yield* kv.set(configKey(entry.origin), configs)
return configs
}),
cached: Effect.fn("WellKnown.cached")(function* (origin) {
const value = yield* kv.get(configKey(origin))
return Schema.is(Configs)(value) ? [...value] : []
}),
resolve: Effect.fn("WellKnown.resolveEntry")((entry, variables) =>
resolveEntry(entry, variables).pipe(Effect.provideService(HttpClient.HttpClient, http)),
),
})
}),
)
+1 -143
View File
@@ -4,7 +4,7 @@ import { describe, expect, test } from "bun:test"
import { Effect, Fiber, Layer, Logger, Schema, Stream } from "effect"
import { FastCheck } from "effect/testing"
import { Config } from "@opencode/core/config"
import { Directory, Document, type Entry, Event, Info } from "@opencode/schema/config"
import { Directory, Document, Event, Info } from "@opencode/schema/config"
import { ConfigModel } from "@opencode/schema/config/model"
import { ConfigProvider } from "@opencode/schema/config/provider"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
@@ -21,7 +21,6 @@ import { Project } from "@opencode/core/project"
import { Provider } from "@opencode/core/provider"
import { AbsolutePath } from "@opencode/core/schema"
import { WellKnown } from "@opencode/core/wellknown"
import { KV } from "@opencode/core/kv"
import { Integration } from "@opencode/schema/integration"
import { emptyCredentialNode, emptyWellknownNode } from "../fixture/config-nodes"
import { location } from "../fixture/location"
@@ -513,7 +512,6 @@ describe("Config", () => {
available
? Effect.succeed([{ shell: variables.TOKEN }])
: Effect.fail(new Error("expired credential")),
cached: () => Effect.succeed([]),
}),
),
deps: [],
@@ -552,145 +550,6 @@ describe("Config", () => {
),
)
it.live("keeps the last resolved wellknown config across failures and restarts until its source is removed", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
let manifestStatus = 200
let configStatus = 200
let requests = 0
let key: string | undefined = "secret"
const server = Bun.serve({
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/.well-known/opencode") {
if (manifestStatus !== 200) return new Response("Unavailable", { status: manifestStatus })
return Response.json({
auth: { command: ["login"], env: "TOKEN" },
remote_config: { url: `${url.origin}/config`, headers: { authorization: "Bearer {env:TOKEN}" } },
})
}
requests++
if (configStatus !== 200) return new Response("Unavailable", { status: configStatus })
return Response.json({
config: {
enabled_providers: ["remote"],
provider: { remote: { npm: "@ai-sdk/openai-compatible", models: { chat: {} } } },
},
})
},
})
const origin = server.url.origin
const integrationID = Integration.ID.make(origin)
const credentialNode = makeGlobalNode({
service: Credential.Service,
layer: Layer.mock(Credential.Service)({
list: () =>
Effect.succeed(
key === undefined
? []
: [
new Credential.Info({
id: Credential.ID.create(),
integrationID,
label: "default",
value: Credential.Key.make({ type: "key", key }),
}),
],
),
}),
deps: [],
})
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
const remote = (entries: readonly Entry[]) => ({
provider: Config.latest(entries, "providers")?.remote?.package,
policies: Config.latest(entries, "experimental")?.policies,
})
const loaded = {
provider: "aisdk:@ai-sdk/openai-compatible",
policies: [
{ action: "provider.use", resource: "*", effect: "deny" },
{ action: "provider.use", resource: "remote", effect: "allow" },
],
} as const
const removed = { provider: undefined, policies: undefined }
return Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(project, { recursive: true }))
const kv = Layer.succeedContext(yield* Layer.build(LayerNode.compile(KV.node)))
// Each build is a fresh process sharing one persisted KV store.
const instance = () =>
AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node, WellKnown.node]), [
Location.node.replace(
Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(project) })),
),
),
Global.node.replace(Global.layerWith({ config: global, home: path.join(global, "home") })),
Credential.node.replace(credentialNode),
KV.node.replace(kv),
Watcher.node.replace(Watcher.testLayer),
])
const services = Effect.gen(function* () {
const config = yield* Config.Service
const bus = yield* Bus.Service
// Each reload also rewrites a local file so Config publishes an update to wait on.
const reload = Effect.fnUntraced(function* (shell: string, trigger: Effect.Effect<unknown, Error>) {
const updated = yield* bus
.subscribe(Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped({ startImmediately: true }))
yield* Effect.promise(() => fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ shell })))
yield* trigger
yield* Fiber.join(updated)
return remote(yield* config.entries())
})
const switched = bus.publish(
Credential.Event.Switched,
{ credentialID: Credential.ID.create(), integrationID },
{ global: true },
)
return { config, bus, wellknown: yield* WellKnown.Service, reload, switched }
})
yield* Effect.gen(function* () {
const warm = yield* services
expect(yield* warm.reload("initial", warm.wellknown.add(origin))).toEqual(loaded)
configStatus = 503
const before = requests
expect(yield* warm.reload("unavailable", warm.switched)).toEqual(loaded)
expect(requests).toBeGreaterThan(before)
key = undefined
expect(yield* warm.reload("logged-out", warm.switched)).toEqual(loaded)
manifestStatus = 503
const updated = yield* warm.bus
.subscribe(WellKnown.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped({ startImmediately: true }))
expect(yield* warm.wellknown.refresh()).toBe(false)
yield* Effect.sleep("50 millis")
expect(updated.pollUnsafe()).toBeUndefined()
}).pipe(Effect.scoped, Effect.provide(instance()))
key = "secret"
yield* Effect.gen(function* () {
const cold = yield* services
expect(remote(yield* cold.config.entries())).toEqual(loaded)
// The wellknown plugin loads these entries at startup and dies if they fail.
expect(yield* cold.wellknown.entries()).toHaveLength(1)
expect(yield* cold.reload("removed", cold.wellknown.remove(origin))).toEqual(removed)
manifestStatus = 200
expect(yield* cold.reload("re-added", cold.wellknown.add(origin))).toEqual(removed)
}).pipe(Effect.scoped, Effect.provide(instance()))
}).pipe(Effect.ensuring(Effect.promise(() => server.stop(true))))
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("logs redacted source-aware diagnostics for every config source", () => {
const output: Array<Record<string, unknown>> = []
const logger = Logger.map(Logger.formatStructured, (entry) => {
@@ -754,7 +613,6 @@ describe("Config", () => {
remove: () => Effect.die("unused Wellknown.remove"),
// Exercise the loader boundary against a malformed implementation response.
resolve: () => Effect.succeed([null as unknown as WellKnown.Config]),
cached: () => Effect.succeed([]),
}),
),
deps: [],
@@ -31,7 +31,6 @@ export const emptyWellknownNode = makeGlobalNode({
add: () => Effect.die("unused Wellknown.add"),
remove: () => Effect.die("unused Wellknown.remove"),
resolve: () => Effect.die("unused Wellknown.resolve"),
cached: () => Effect.succeed([]),
}),
),
deps: [],
+1 -34
View File
@@ -845,16 +845,7 @@ const fragmentFixture = (kind: FragmentKind, id: string, chunks: readonly string
return {
partialEvents,
completeEvents: [...partialEvents, LLMEvent.toolInputEnd({ id, name: "echo" })],
expectedAssistant: {
type: "assistant",
content: [
{
type: "tool",
id,
state: { status: "error", input: {}, error: { type: "tool.input-incomplete" } },
},
],
},
expectedAssistant: { type: "assistant", content: [expectedContent] },
expectedContent,
}
}
@@ -6032,30 +6023,6 @@ describe("SessionRunnerLLM", () => {
])
})
scenario("settles unfinished tool input after an output limit", function* (s) {
yield* s.llm.push(
TestLLM.complete(
{ reason: { normalized: "length" } },
LLMEvent.toolInputStart({ id: "call-incomplete", name: "echo" }),
LLMEvent.toolInputDelta({ id: "call-incomplete", name: "echo", text: '{"text":"partial' }),
),
TestLLM.stop(),
)
yield* s.runPrompt("Recover unfinished tool input")
expect(s.requests).toHaveLength(2)
expect(s.executions).toEqual([])
expect(requireAssistant(yield* s.context).content).toMatchObject([
{
type: "tool",
id: "call-incomplete",
executed: false,
state: { status: "error", error: { type: "tool.input-incomplete" } },
},
])
})
scenario("continues after malformed local tool input without exposing raw arguments", function* (s) {
const marker = "raw-malformed-marker"
const raw = `{"text":"${marker}`
+39 -8
View File
@@ -110,6 +110,15 @@ describe("WriteTool", () => {
Effect.gen(function* () {
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["write", "execute"])
const settled = yield* executeTool(registry, call({ path: "src/new.txt", content: "created" }))
const files = [
{
file: "src/new.txt",
status: "added",
additions: 1,
deletions: 0,
patch: expect.stringContaining("+created"),
},
]
expect(settled).toEqual({
status: "completed",
output: {
@@ -117,8 +126,10 @@ describe("WriteTool", () => {
target: path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt"),
resource: "src/new.txt",
existed: false,
files,
},
content: [{ type: "text", text: "Created file successfully: src/new.txt" }],
metadata: { files },
})
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "src", "new.txt"), "utf8"))).toBe(
"created",
@@ -145,7 +156,7 @@ describe("WriteTool", () => {
}),
)
it.live("formats the committed file", () =>
it.live("formats the committed file and returns the diff for final formatted content", () =>
withTempDir((tmp) => {
const fixture = makeWriteFixture()
const target = path.join(tmp.path, "formatted.txt")
@@ -154,13 +165,33 @@ describe("WriteTool", () => {
await fs.writeFile(file, (await fs.readFile(file, "utf8")).toUpperCase())
return true
})
return withTool(tmp.path, fixture, (registry) =>
Effect.gen(function* () {
expect(yield* executeTool(registry, call({ path: "formatted.txt", content: "format me" }))).toMatchObject({
status: "completed",
})
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("FORMAT ME")
}),
return Effect.promise(() => fs.writeFile(target, "before\n")).pipe(
Effect.andThen(
withTool(tmp.path, fixture, (registry) =>
executeTool(registry, call({ path: "formatted.txt", content: "format me\n" })),
),
),
Effect.andThen((settled) =>
Effect.gen(function* () {
expect(settled.status).toBe("completed")
if (settled.status !== "completed") return
const files = [
{
file: "formatted.txt",
status: "modified",
additions: 1,
deletions: 1,
patch: expect.stringMatching(/-before\n\+FORMAT ME\n/),
},
]
expect(settled.output).toMatchObject({ files })
expect(settled.metadata).toMatchObject({ files })
expect(fixture.assertions[0]?.metadata).toMatchObject({
files: [{ patch: expect.stringContaining("+format me") }],
})
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("FORMAT ME\n")
}),
),
)
}),
)
-5
View File
@@ -15,11 +15,6 @@ export const LANGUAGE_EXTENSIONS: Record<string, string> = {
".cxx": "cpp",
".cc": "cpp",
".c++": "cpp",
".cppm": "cpp",
".ccm": "cpp",
".cxxm": "cpp",
".c++m": "cpp",
".ixx": "cpp",
".cs": "csharp",
".csx": "csharp",
".css": "css",