mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-08 08:18:32 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f2b10993d |
No files matched your search
@@ -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")
|
||||
}),
|
||||
|
||||
@@ -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" } },
|
||||
],
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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", () => [
|
||||
{
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 })),
|
||||
),
|
||||
}),
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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")
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user