Compare commits

...

35 Commits

Author SHA1 Message Date
Kit Langton b259709590 fix(core): attribute deferred skill plugin failures 2026-09-02 22:45:07 -04:00
Kit Langton efefd90443 feat(plugin): add reference editor lookup 2026-09-03 01:59:36 +00:00
Dax Raad 2b87169cc1 feat(tui): polish plugin dialog sizing, actions, and local footer 2026-09-02 21:55:48 -04:00
Kit Langton 962c26bdf2 feat(plugin): add skill editor lookup 2026-09-02 21:55:00 -04:00
David Hill 22de01e84f fix(app): animate subagent card chevron (#46893) 2026-09-03 09:47:53 +08:00
Aiden Cline 8565cb52a1 chore(ai): clean up responses item id comments (#46951) 2026-09-02 20:44:30 -05:00
Kit Langton 050398f51f fix(core): preserve provider identity in catalog updates 2026-09-03 01:44:12 +00:00
Kit Langton 5f1d74fd3f fix(plugin): export Promise ToolEditor 2026-09-03 01:40:03 +00:00
Aiden Cline d9c85d8d95 refactor(ai): resolve responses item ids once at the stream boundary (#46885) 2026-09-02 20:31:00 -05:00
Kit Langton 1c77b1c920 refactor(core): remove unused repository cache success timestamp (#46942) 2026-09-02 21:17:59 -04:00
Kit Langton 27f838f249 fix(client): refresh references for the updated location (#46935) 2026-09-02 21:12:52 -04:00
Kit Langton c992716523 fix(core): activate the initial plugin generation without the reload debounce (#46922) 2026-09-03 01:02:38 +00:00
Kit Langton e402600d92 test(core): keep supervisor reload tests offline (#46939) 2026-09-02 21:02:16 -04:00
opencode-agent[bot] 1e4e9c5d85 fix(cli): disable bytecode until Bun 1.4.1 (#46933)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-09-03 10:43:09 +10:00
Kit Langton f34b74b1dc test(core): refuse network in the test harness (#46908) 2026-09-02 20:41:29 -04:00
Kit Langton b257d476c9 refactor(core): preserve normalized tool results (#46927) 2026-09-02 20:39:55 -04:00
Kit Langton 5e38101fba refactor(client): derive session inputs from pending items (#46926) 2026-09-02 20:24:35 -04:00
mayamika 4beaffbda9 fix(app): show review diffs for non-git VCS backends (#46684)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 10:04:24 +10:00
opencode-agent[bot] 21bbcc33a1 fix(app): restore uniform new session tab width (#46919)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-03 08:01:11 +08:00
Kit Langton 0b1dbaf621 refactor(client): edit streamed message targets directly (#46924) 2026-09-02 20:00:25 -04:00
Dax Raad 0da51e9274 fix(updates): retry transient artifact publication failures 2026-09-02 19:30:11 -04:00
Dax dea1f9cb7b fix(core): update plugin reload npm fixture (#46918) 2026-09-02 19:12:00 -04:00
Dax 969055918f feat(cli): use endpoint-provided update packages (#46910) 2026-09-02 23:01:27 +00:00
Kit Langton f269025416 fix(core): keep plugin activation stable across failures and refreshes (#46899) 2026-09-02 18:56:23 -04:00
Dax Raad 4643cacb4a fix(cli): retry Windows service smoke cleanup 2026-09-02 18:55:26 -04:00
Dax Raad 68a40e2d21 chore: remove package reservation script 2026-09-02 18:15:53 -04:00
Dax 57c02cd04b fix(tui): refresh plugin dialog after updates (#46911) 2026-09-02 17:59:18 -04:00
Dax Raad 46515df4a3 refactor(updates): use useragent event field 2026-09-02 17:51:55 -04:00
Dax Raad 51250e4346 feat(updates): include client IP in request events 2026-09-02 17:51:33 -04:00
Aiden Cline dbd47702b2 fix(core): tell plan agent to discuss plans instead of writing files by default (#46905) 2026-09-02 16:49:04 -05:00
Dax Raad a085bf62a4 feat(updates): log requests to the shared data lake 2026-09-02 17:25:02 -04:00
Luke Parker b605f355ca fix(core): share the models.dev snapshot across Locations (#46784) 2026-09-03 07:22:07 +10:00
opencode-agent[bot] 85e2b0a23a chore: update nix node_modules hashes 2026-09-02 21:21:54 +00:00
Kit Langton 3dc187bf3f refactor(client): share location resource reads (#46831) 2026-09-02 17:05:09 -04:00
Dax ed3259a9b7 refactor(plugin): centralize host resolution (#46901) 2026-09-02 17:04:15 -04:00
143 changed files with 3114 additions and 1682 deletions
+1 -1
View File
@@ -47,7 +47,7 @@ jobs:
- uses: ./.github/actions/setup-bun
- name: Deploy update service
if: github.ref_name == 'v2' || github.ref_name == 'beta'
if: github.ref_name == 'v2'
working-directory: packages/updates
run: bun run deploy
env:
+2 -4
View File
@@ -575,6 +575,7 @@
"@opencode-ai/client": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
"zod": "catalog:",
@@ -585,6 +586,7 @@
"@opentui/solid": "catalog:",
"@tsconfig/bun": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"solid-js": "catalog:",
@@ -971,7 +973,6 @@
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"pacote": "21.5.1",
"resolve.exports": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
@@ -1123,7 +1124,6 @@
"opentui-spinner": "0.0.7",
"remeda": "2.26.0",
"remend": "1.3.1",
"resolve.exports": "2.0.3",
"semver": "7.7.4",
"shiki": "4.4.3",
"solid-js": "1.9.15",
@@ -5171,8 +5171,6 @@
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"resolve.exports": ["resolve.exports@2.0.3", "", {}, "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A=="],
"responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="],
"restructure": ["restructure@3.0.2", "", {}, "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-Vazzj75ji5YbLtX5X0q+IZvygGrfmEi3+PzJsAkaa3s=",
"aarch64-linux": "sha256-QFKzH7wlRcvYG4EHQgQ+++9zNQ+Mw5DVKembGiMjiCg=",
"aarch64-darwin": "sha256-WAYBBA2jLW95wM8Dgk77KsO3yS5efxQs9oBVAcdx8Gc=",
"x86_64-darwin": "sha256-c9P+VQvfdOu5ef6BnKXb3/IUo8pPFaYQv8B+ijofm1s="
"x86_64-linux": "sha256-j3Vtfd5sx+hHnV3qHBq7Ae96E08meNqTqtYN9kWmCqE=",
"aarch64-linux": "sha256-LCvHEYoDgvkXtFFs0u7XBEzyo8fGGX25N8iuLrHSu8I=",
"aarch64-darwin": "sha256-wBRx1e8x6toEHV1sFMDDs26+s8IHyPAqCg6TcBgsxxA=",
"x86_64-darwin": "sha256-ClVls/CTpT76W5SQ/cij2YFmBqv9FGLeVxQh8iStnEk="
}
}
-2
View File
@@ -27,7 +27,6 @@
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
"postinstall": "bun run --cwd packages/core fix-node-pty",
"prepare": "husky",
"reserve-packages": "bun script/reserve-package-names.ts",
"random": "echo 'Random script'",
"sso": "aws sso login --sso-session=opencode --no-browser",
"test": "echo 'do not run tests from root' && exit 1"
@@ -93,7 +92,6 @@
"@typescript/native-preview": "7.0.0-dev.20251207.1",
"zod": "4.1.8",
"remeda": "2.26.0",
"resolve.exports": "2.0.3",
"sst": "4.13.1",
"shiki": "4.4.3",
"solid-list": "0.3.0",
+84 -86
View File
@@ -304,6 +304,11 @@ export const OpenResponsesUsage = Schema.Struct({
})
type OpenResponsesUsage = Schema.Schema.Type<typeof OpenResponsesUsage>
// The spec requires `id` on every output item, but some gateways drop it from
// later item events (Bedrock Mantle renames it to `item_id` on
// `output_item.done` and `response.completed.output`). Decode it as optional
// and let `normalize` recover or mint it once before the parser runs.
// https://www.openresponses.org/specification#extending-items
export const StreamItem = Schema.StructWithRest(
Schema.Struct({
type: Schema.String,
@@ -316,6 +321,7 @@ export const StreamItem = Schema.StructWithRest(
[Schema.Record(Schema.String, Schema.Unknown)],
)
export type StreamItem = Schema.Schema.Type<typeof StreamItem>
export type OutputItem = StreamItem & { readonly id: string }
// The Responses schema puts streaming error details at the top level and
// response failures under `response.error`. WebSocket failures use an
@@ -395,6 +401,7 @@ export const Event = Schema.StructWithRest(
[Schema.Record(Schema.String, Schema.Unknown)],
)
export type Event = Schema.Schema.Type<typeof Event>
export type NormalizedEvent = Event & { readonly item?: OutputItem | null }
export interface ProviderAdapter {
readonly id: string
@@ -416,14 +423,13 @@ export interface ParserState {
readonly name: string
readonly providerMetadataKey: string
readonly tools: ToolStream.State<string>
// Call ids stay independent of item ids, which may be omitted or reused.
// Item ids are response-scoped identities. Keep completed ids tombstoned so
// reconnect replay cannot reopen fragments already emitted downstream.
readonly completedTools: ReadonlySet<string>
readonly hasFunctionCall: boolean
readonly lifecycle: Lifecycle.State
readonly outputItems: Readonly<Record<number, string>>
readonly message: { readonly id: string; readonly phase: MessagePhase | null | undefined } | undefined
// Item ids are response-scoped identities. Keep completed ids tombstoned so
// reconnect replay cannot reopen fragments already emitted downstream.
readonly completedMessages: ReadonlySet<string>
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
}
@@ -875,9 +881,6 @@ export const providerMetadata = (state: ParserState, metadata: Record<string, un
[state.providerMetadataKey]: metadata,
})
const isReasoningItem = (item: StreamItem): item is StreamItem & { type: "reasoning"; id: string } =>
item.type === "reasoning" && typeof item.id === "string"
export type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
const NO_EVENTS: StepResult["1"] = []
@@ -921,9 +924,34 @@ const joinReasoningText = (parts: ReadonlyArray<string | undefined>) => {
return parts.filter((part) => part !== undefined).join("\n\n")
}
export const outputItemID = (state: ParserState, event: Event) =>
const outputItemID = (state: ParserState, event: Event) =>
event.output_index === undefined ? event.item_id : (state.outputItems[event.output_index] ?? event.item_id)
const ITEM_ID_PREFIX: Readonly<Record<string, string>> = {
message: "msg",
reasoning: "rs",
function_call: "fc",
compaction: "cmp",
}
// An item without an id adopts the id already open in its output slot,
// otherwise it gets a locally minted one.
const resolveItem = (state: ParserState, item: StreamItem, index: number | undefined): OutputItem => ({
...item,
id:
item.id ??
(index === undefined ? undefined : state.outputItems[index]) ??
`${ITEM_ID_PREFIX[item.type] ?? "item"}_${crypto.randomUUID().replaceAll("-", "")}`,
})
// Registered output slots are authoritative for `item_id` routing, and items
// are resolved here so everything downstream can rely on `item.id`.
export const normalize = (state: ParserState, input: Event): NormalizedEvent => ({
...input,
item_id: input.item_id === undefined ? undefined : outputItemID(state, input),
item: input.item ? resolveItem(state, input.item, input.output_index) : input.item,
})
const startReasoningSummaryPart = (state: ParserState, itemID: string, index: number): StepResult => {
const item = state.reasoningItems[itemID]
if (!item?.open || index === 0 || item.summaryParts[index] !== undefined) return [state, NO_EVENTS]
@@ -997,7 +1025,7 @@ export const onReasoningDone = (state: ParserState, event: Event, itemID: string
return onReasoningDelta(state, { ...event, delta: event.text }, itemID)
}
const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }) =>
const reasoningMetadata = (state: ParserState, item: OutputItem) =>
providerMetadata(state, { itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
// Responses APIs normally stream reasoning items in this order:
@@ -1010,18 +1038,18 @@ const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }
// `onOutputItemAdded` seeds the per-item entry, while each later part start is
// also an implicit boundary for the previous part. This keeps the common event
// lifecycle ordered when a compatible provider omits or delays a part-done event.
const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
const onOutputItemAdded = (state: ParserState, event: NormalizedEvent): StepResult => {
const item = event.item
if (item?.type === "message" && item.id !== undefined) {
const itemID = item.id
if (state.completedMessages.has(itemID)) return [state, NO_EVENTS]
if (!item) return [state, NO_EVENTS]
if (item.type === "message") {
if (state.completedMessages.has(item.id)) return [state, NO_EVENTS]
const phase = messagePhase(item.phase)
const completedMessages = new Set(state.completedMessages)
if (state.message !== undefined && state.message.id !== itemID) completedMessages.add(state.message.id)
if (state.message !== undefined && state.message.id !== item.id) completedMessages.add(state.message.id)
// A new message closes earlier messages, including ones that never streamed.
const events: LLMEvent[] = []
const lifecycle = [...state.lifecycle.text]
.filter((id) => id !== itemID)
.filter((id) => id !== item.id)
.reduce((lifecycle, id) => {
completedMessages.add(id)
const openPhase = state.message?.id === id ? state.message.phase : undefined
@@ -1038,14 +1066,14 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
lifecycle,
completedMessages,
message: {
id: itemID,
phase: phase === undefined && state.message?.id === itemID ? state.message.phase : phase,
id: item.id,
phase: phase === undefined && state.message?.id === item.id ? state.message.phase : phase,
},
},
events,
]
}
if (item && isReasoningItem(item)) {
if (item.type === "reasoning") {
if (state.reasoningItems[item.id] !== undefined) return [state, NO_EVENTS]
const events: LLMEvent[] = []
return [
@@ -1065,18 +1093,16 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
events,
]
}
if (item?.type !== "function_call" || !item.call_id) return [state, NO_EVENTS]
const id = item.id ?? item.call_id
if (Object.values(state.tools).some((tool) => tool?.id === item.call_id) || state.completedTools.has(item.call_id))
return [state, NO_EVENTS]
const metadata = item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined
if (item.type !== "function_call" || !item.call_id) return [state, NO_EVENTS]
if (state.tools[item.id] !== undefined || state.completedTools.has(item.id)) return [state, NO_EVENTS]
const metadata = providerMetadata(state, { itemId: item.id })
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
return [
{
...state,
lifecycle,
tools: ToolStream.start(state.tools, id, {
tools: ToolStream.start(state.tools, item.id, {
id: item.call_id,
name: item.name ?? "",
input: item.arguments ?? "",
@@ -1148,13 +1174,13 @@ const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgu
const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
state: ParserState,
item: Event["item"],
item: NormalizedEvent["item"],
) {
if (!item) return [state, NO_EVENTS] satisfies StepResult
if (item.type === "compaction") {
if (!item.id || typeof item.encrypted_content !== "string")
return yield* ProviderShared.eventError(state.id, "Compaction output is missing its id or encrypted content")
if (typeof item.encrypted_content !== "string")
return yield* ProviderShared.eventError(state.id, "Compaction output is missing its encrypted content")
if (state.completedCompactions.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
@@ -1171,7 +1197,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
] satisfies StepResult
}
if (item.type === "message" && item.id !== undefined) {
if (item.type === "message") {
if (state.completedMessages.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
const completedMessages = new Set(state.completedMessages)
completedMessages.add(item.id)
@@ -1204,36 +1230,23 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (item.type === "function_call") {
if (!item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
const callID = item.call_id
if (state.completedTools.has(callID)) return [state, NO_EVENTS] satisfies StepResult
const metadata = item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined
const fallback = item.id ?? callID
// Match the pending tool by call id so item events that disagree on
// whether `item.id` is present still resolve the same call.
const registered =
state.tools[fallback] !== undefined
? fallback
: Object.keys(state.tools).find((key) => state.tools[key]?.id === callID)
const id = registered ?? fallback
const tools =
registered !== undefined
? state.tools
: ToolStream.start(state.tools, id, {
id: callID,
name: item.name,
providerMetadata: metadata,
})
if (state.completedTools.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
const metadata = providerMetadata(state, { itemId: item.id })
const registered = state.tools[item.id] !== undefined
const tools = registered
? state.tools
: ToolStream.start(state.tools, item.id, { id: item.call_id, name: item.name, providerMetadata: metadata })
const result =
item.arguments === undefined
? yield* ToolStream.finish(state.id, tools, id)
: yield* ToolStream.finishWithInput(state.id, tools, id, item.arguments)
? yield* ToolStream.finish(state.id, tools, item.id)
: yield* ToolStream.finishWithInput(state.id, tools, item.id, item.arguments)
const events: LLMEvent[] = []
const finished = result.events ?? []
// A done-only call never streamed a start event, so open its lifecycle here.
const resultEvents =
registered !== undefined || finished.length === 0
registered || finished.length === 0
? finished
: [LLMEvent.toolInputStart({ id: callID, name: item.name, providerMetadata: metadata }), ...finished]
: [LLMEvent.toolInputStart({ id: item.call_id, name: item.name, providerMetadata: metadata }), ...finished]
const lifecycle = resultEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
events.push(...resultEvents)
return [
@@ -1244,13 +1257,13 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
resultEvents.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
state.hasFunctionCall,
tools: result.tools,
completedTools: new Set([...state.completedTools, callID]),
completedTools: new Set([...state.completedTools, item.id]),
},
events,
] satisfies StepResult
}
if (isReasoningItem(item)) {
if (item.type === "reasoning") {
if (state.reasoningItems[item.id]?.open === false) return [state, NO_EVENTS] satisfies StepResult
const metadata = reasoningMetadata(state, item)
const summaryParts: ReadonlyArray<unknown> = Array.isArray(item.summary) ? item.summary : []
@@ -1334,21 +1347,17 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
let current = state
const events: LLMEvent[] = []
if (event.type === "response.completed") {
for (const item of event.response?.output ?? []) {
if (item.type !== "compaction" && item.type !== "function_call") continue
if (item.type === "compaction") {
// Terminal recovery cannot insert a checkpoint before already-emitted content.
if (state.lifecycle.stepStarted && !state.completedCompactions.has(item.id ?? ""))
return yield* ProviderShared.eventError(
state.id,
"Cannot recover a compaction checkpoint after output has been emitted",
)
}
if (
item.type === "function_call" &&
(!item.call_id || !Object.values(current.tools).some((tool) => tool?.id === item.call_id))
)
continue
// An output item's array position is its output index.
for (const item of (event.response?.output ?? []).map((item, index) => resolveItem(state, item, index))) {
// Terminal recovery cannot insert a checkpoint before already-emitted content.
if (item.type === "compaction" && state.lifecycle.stepStarted && !state.completedCompactions.has(item.id))
return yield* ProviderShared.eventError(
state.id,
"Cannot recover a compaction checkpoint after output has been emitted",
)
const recoverable =
item.type === "compaction" || (item.type === "function_call" && current.tools[item.id] !== undefined)
if (!recoverable) continue
const [next, emitted] = yield* onOutputItemDone(current, item)
current = next
events.push(...emitted)
@@ -1415,12 +1424,9 @@ export const providerFailure = (event: Event, fallback: string, body = ProviderS
return new AIError({ reason })
}
export const step = (state: ParserState, input: Event) => {
// The OpenAPI requires string IDs but imposes no minLength; empty is not missing.
const event =
input.item_id !== undefined && outputItemID(state, input) !== input.item_id
? { ...input, item_id: outputItemID(state, input) }
: input
// Callers must pass events through `normalize` first. The OpenAPI requires
// string IDs but imposes no minLength; empty is not missing.
export const step = (state: ParserState, event: NormalizedEvent) => {
if (event.type === "response.output_text.delta" || event.type === "response.output_text.done") {
if (event.item_id === undefined) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(
@@ -1460,20 +1466,16 @@ export const step = (state: ParserState, input: Event) => {
? Effect.succeed(onReasoningSummaryPartDone(state, event))
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.type === "response.output_item.added") {
if (event.item?.type === "message" && event.item.id === undefined)
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
if (
event.item &&
isReasoningItem(event.item) &&
event.item?.type === "reasoning" &&
state.reasoningItems[event.item.id] === undefined &&
state.lifecycle.reasoning.size > 0
)
return ProviderShared.eventError(state.id, `${event.type} started reasoning before the previous item ended`)
const id = event.item?.id ?? (event.item?.type === "function_call" ? event.item.call_id : undefined)
return Effect.succeed(
onOutputItemAdded(
event.output_index !== undefined && id !== undefined
? { ...state, outputItems: { ...state.outputItems, [event.output_index]: id } }
event.output_index !== undefined && event.item
? { ...state, outputItems: { ...state.outputItems, [event.output_index]: event.item.id } }
: state,
event,
),
@@ -1483,11 +1485,7 @@ export const step = (state: ParserState, input: Event) => {
return event.item_id !== undefined
? onFunctionCallArgumentsDelta(state, event)
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done") {
if (event.item?.type === "message" && event.item.id === undefined)
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
return onOutputItemDone(state, event.item)
}
if (event.type === "response.output_item.done") return onOutputItemDone(state, event.item)
if (event.type === "response.completed" || event.type === "response.incomplete") return onResponseFinish(state, event)
if (event.type === "response.failed") return providerFailure(event, `${state.name} response failed`)
if (event.type === "error")
@@ -1537,7 +1535,7 @@ export const protocol = Protocol.make({
stream: {
event: Protocol.jsonEvent(Event),
initial,
step,
step: (state: ParserState, event: Event) => step(state, normalize(state, event)),
terminal,
},
})
@@ -201,12 +201,11 @@ const HOSTED_TOOLS = {
},
} as const satisfies ResponsesHostedTools.Definitions
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
const step = (state: OpenResponses.ParserState, input: OpenResponses.Event) => {
const event = OpenResponses.normalize(state, input)
if (event.type === "response.reasoning_text.delta")
return event.item_id !== undefined
? Effect.succeed(
OpenResponses.onReasoningDelta(state, event, OpenResponses.outputItemID(state, event) ?? event.item_id),
)
? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id))
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
@@ -3,8 +3,7 @@ import { LLMEvent, type AIError, type ToolResultPart } from "../../schema/index.
import { OpenResponses } from "../open-responses.js"
import { Lifecycle } from "./lifecycle.js"
export type Item = OpenResponses.StreamItem & {
readonly id: string
export type Item = OpenResponses.OutputItem & {
readonly status?: string
readonly action?: unknown
readonly queries?: unknown
@@ -27,8 +26,8 @@ export interface Definition {
export type Definitions = Readonly<Record<string, Definition>>
export const isItem = <Tools extends Definitions>(item: OpenResponses.StreamItem, tools: Tools): item is Item =>
item.type in tools && typeof item.id === "string" && item.id.length > 0
export const isItem = <Tools extends Definitions>(item: OpenResponses.OutputItem, tools: Tools): item is Item =>
item.type in tools
export const onDone: (
state: OpenResponses.ParserState,
+2 -1
View File
@@ -69,7 +69,8 @@ const HOSTED_TOOLS = {
// Grok speaks the standard Responses reasoning dialect (`reasoning_summary_text.*`,
// handled by the baseline); only its hosted tool vocabulary differs.
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
const step = (state: OpenResponses.ParserState, input: OpenResponses.Event) => {
const event = OpenResponses.normalize(state, input)
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
return OpenResponses.step(state, event)
+8 -5
View File
@@ -125,12 +125,15 @@ testEffect(
response: { output: [{ type: "compaction", encrypted_content: "opaque" }] },
}),
),
).effect("rejects terminal checkpoints missing an id", () =>
).effect("mints an id for terminal checkpoints that omit one", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(
const response = yield* LLMClient.generate(
LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("fixture"), prompt: "hello" }),
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain("missing its id")
)
const part = response.message.content[0]
expect(part?.type).toBe("compaction")
if (part?.type !== "compaction") return
expect(part.id).toMatch(/^cmp_[0-9a-f]{32}$/)
expect(part.encrypted).toBe("opaque")
}),
)
@@ -329,69 +329,126 @@ describe("Open Responses basic-item lifecycles", () => {
])
}),
)
;[undefined, "fc_1"].forEach((id) => {
it.effect(`opens and closes a done-only tool ${id === undefined ? "without" : "with"} an item id`, () =>
Effect.gen(function* () {
const item = {
type: "function_call",
...(id === undefined ? {} : { id }),
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
}
const events = yield* collect(
{ type: "response.output_item.done", item },
{ type: "response.output_item.done", item: { ...item, id: "fc_1" } },
{ type: "response.output_item.added", item },
completed,
)
const providerMetadata = id === undefined ? undefined : { "openai-compatible": { itemId: id } }
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, providerMetadata },
])
expect(events.filter(LLMEvent.is.finish)).toEqual([
{
type: "finish",
reason: { normalized: "tool-calls", raw: undefined },
providerMetadata: { "openai-compatible": { responseId: "resp_1", serviceTier: undefined } },
// Captured from Bedrock Mantle (openai.gpt-oss-120b): the terminal function_call
// items rename `id` to `item_id` and carry a stray `output_index`.
it.effect("recovers a terminal function_call id from its output slot", () =>
Effect.gen(function* () {
const terminal = {
type: "function_call",
item_id: "fc_828bee50dee1d029",
call_id: "call_bc1eb4b42e70ee53",
name: "get_weather",
arguments: '{\n "city": "Paris"\n}',
output_index: 1,
status: "completed",
}
const events = yield* collect(
{
type: "response.output_item.added",
output_index: 0,
item: { type: "reasoning", id: "msg_879a68b589198b4c" },
},
{ type: "response.output_item.done", output_index: 0, item: { type: "reasoning", id: "msg_879a68b589198b4c" } },
{
type: "response.output_item.added",
output_index: 1,
item: {
type: "function_call",
id: "fc_828bee50dee1d029",
call_id: "call_bc1eb4b42e70ee53",
name: "get_weather",
arguments: "",
status: "in_progress",
},
])
}),
)
},
{
type: "response.function_call_arguments.delta",
output_index: 1,
item_id: "fc_828bee50dee1d029",
delta: '{\n "city": "Paris"\n}',
},
{
type: "response.function_call_arguments.done",
output_index: 1,
item_id: "fc_828bee50dee1d029",
arguments: '{\n "city": "Paris"\n}',
},
{ type: "response.output_item.done", output_index: 1, item: terminal },
{
type: "response.completed",
response: { id: "resp_1", output: [{ type: "reasoning", id: "msg_879a68b589198b4c" }, terminal] },
},
)
const providerMetadata = { "openai-compatible": { itemId: "fc_828bee50dee1d029" } }
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
{ type: "tool-input-start", id: "call_bc1eb4b42e70ee53", name: "get_weather", providerMetadata },
{
type: "tool-input-delta",
id: "call_bc1eb4b42e70ee53",
name: "get_weather",
text: '{\n "city": "Paris"\n}',
input: { city: "Paris" },
},
{ type: "tool-input-end", id: "call_bc1eb4b42e70ee53", name: "get_weather", providerMetadata },
{
type: "tool-call",
id: "call_bc1eb4b42e70ee53",
name: "get_weather",
input: { city: "Paris" },
providerMetadata,
},
])
}),
)
it.effect(`deduplicates a pending call whose item id is ${id === undefined ? "introduced" : "omitted"} later`, () =>
Effect.gen(function* () {
const item = { type: "function_call", call_id: "call_1", name: "lookup" }
const first = { ...item, ...(id === undefined ? {} : { id }) }
const duplicate = { ...item, ...(id === undefined ? { id: "fc_1" } : {}) }
const events = yield* collect(
{ type: "response.output_item.added", item: first },
{ type: "response.function_call_arguments.delta", item_id: id ?? "call_1", delta: '{"query":"weather"}' },
{ type: "response.output_item.added", item: duplicate },
{ type: "response.output_item.done", item: duplicate },
{ type: "response.output_item.done", item: first },
{ type: "response.output_item.added", item: duplicate },
completed,
)
// Identity metadata comes from the first admission, not the duplicate.
const providerMetadata = id === undefined ? undefined : { "openai-compatible": { itemId: id } }
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata },
{
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: '{"query":"weather"}',
input: { query: "weather" },
},
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, providerMetadata },
])
}),
)
})
it.effect("mints an id for a done-only tool that never had one", () =>
Effect.gen(function* () {
const events = yield* collect(
{
type: "response.output_item.done",
output_index: 0,
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
},
completed,
)
const call = events.find(LLMEvent.is.toolCall)
expect(call).toMatchObject({ id: "call_1", name: "lookup", input: { query: "weather" } })
expect(call?.providerMetadata?.["openai-compatible"]).toMatchObject({
itemId: expect.stringMatching(/^fc_[0-9a-f]{32}$/),
})
}),
)
it.effect("opens and closes a done-only tool once", () =>
Effect.gen(function* () {
const item = {
type: "function_call",
id: "fc_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
}
const events = yield* collect(
{ type: "response.output_item.done", item },
{ type: "response.output_item.done", item },
{ type: "response.output_item.added", item },
completed,
)
const providerMetadata = { "openai-compatible": { itemId: "fc_1" } }
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, providerMetadata },
])
expect(events.filter(LLMEvent.is.finish)).toEqual([
{
type: "finish",
reason: { normalized: "tool-calls", raw: undefined },
providerMetadata: { "openai-compatible": { responseId: "resp_1", serviceTier: undefined } },
},
])
}),
)
it.effect("recovers pending calls without reconciling terminal reasoning", () =>
Effect.gen(function* () {
@@ -436,21 +493,6 @@ describe("Open Responses basic-item lifecycles", () => {
}),
)
it.effect("preserves call identity and pending order when an item id is reused", () =>
Effect.gen(function* () {
const first = { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "{}" }
const events = yield* collect(
{ type: "response.output_item.added", item: first },
{ type: "response.output_item.added", item: { ...first, id: "fc_2", call_id: "call_2" } },
{ type: "response.output_item.done", item: first },
{ type: "response.output_item.added", item: { ...first, call_id: "call_3" } },
{ type: "response.output_item.done", item: first },
completed,
)
expect(events.filter(LLMEvent.is.toolCall).map((event) => event.id)).toEqual(["call_1", "call_2", "call_3"])
}),
)
it.effect("keeps text and reasoning identities separate even with empty item ids", () =>
Effect.gen(function* () {
const events = yield* collect(
@@ -500,14 +542,15 @@ describe("Open Responses basic-item lifecycles", () => {
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Answer" },
{
type: "response.output_item.added",
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: "{}" },
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "{}" },
},
completed,
)
// Generic terminal closure does not repeat the message's phase metadata.
const providerMetadata = { "openai-compatible": { itemId: "fc_1" } }
expect(events.slice(4, -2)).toEqual([
{ type: "tool-input-end", id: "call_1", name: "lookup" },
{ type: "tool-call", id: "call_1", name: "lookup", input: {} },
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-call", id: "call_1", name: "lookup", input: {}, providerMetadata },
{ type: "text-end", id: "msg_1" },
])
}),
@@ -586,23 +586,21 @@ describe("Open Responses-compatible route", () => {
Effect.gen(function* () {
yield* Effect.forEach(["response.output_item.added", "response.output_item.done"], (type) =>
Effect.forEach(fixtures, (fixture) =>
Effect.forEach(
fixture.item.type === "message" ? [undefined, null, 0, false, {}, []] : [null, 0, false, {}, []],
(id) =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type, item: { ...fixture.item, id } },
{ type: "response.completed", response: { id: "resp_1" } },
),
Effect.forEach([null, 0, false, {}, []], (id) =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type, item: { ...fixture.item, id } },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
}),
),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
}),
),
),
)
@@ -610,43 +608,6 @@ describe("Open Responses-compatible route", () => {
)
})
it.effect("streams function calls without optional item ids through the shared baseline", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
provider: "example",
}).model("example-model")
const item = { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" }
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Look it up." })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 1, item },
{
type: "response.function_call_arguments.delta",
output_index: 1,
item_id: "opaque_item",
delta: '{"query":"shared"}',
},
{
type: "response.output_item.done",
output_index: 1,
item: { ...item, arguments: '{"query":"complete"}' },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "complete" } }),
])
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
}),
)
it.effect("finalizes pending function calls from completed response output", () =>
Effect.gen(function* () {
const model = configure({
@@ -469,7 +469,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("continues an item-id-less tool call with only the new tool output", () =>
it.effect("continues a streamed tool call with only the new tool output", () =>
Effect.gen(function* () {
const firstRequest = {
type: "response.create",
@@ -485,6 +485,7 @@ describe("OpenAI Responses route", () => {
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_1",
status: "completed",
call_id: "call_1",
name: "weather",
@@ -2129,47 +2130,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("routes item-id-less function arguments by output index and prefers item completion", () =>
Effect.gen(function* () {
const item = { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" }
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 2, item },
{
type: "response.function_call_arguments.delta",
output_index: 2,
item_id: "opaque_delta",
delta: '{"query":"streamed"}',
},
{
type: "response.function_call_arguments.done",
output_index: 2,
item_id: "opaque_done",
arguments: '{"query":"arguments-done"}',
},
{
type: "response.output_item.done",
output_index: 2,
item: { ...item, arguments: '{"query":"output-item-done"}' },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.filter((event) => event.type === "tool-input-delta")).toMatchObject([
{ id: "call_1", text: '{"query":"streamed"}' },
])
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "output-item-done" } }),
])
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
}),
)
it.effect("routes reasoning summary events by output index", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
@@ -2389,7 +2349,7 @@ describe("OpenAI Responses route", () => {
{
type: "response.output_item.added",
output_index: 0,
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" },
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "" },
},
event,
{ type: "response.completed", response: { id: "resp_1" } },
@@ -2931,14 +2891,10 @@ describe("OpenAI Responses route", () => {
arguments: '{"query":"weather"}',
},
},
// Duplicates that drop the item id still resolve the same call.
{
type: "response.output_item.done",
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
},
// A completed item that is re-added stays closed.
{
type: "response.output_item.added",
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" },
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
@@ -3793,43 +3749,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("finalizes and replays a completed function call without an optional item id", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.done",
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "weather" } }),
])
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
response.message,
Message.tool({ id: "call_1", name: "lookup", resultType: "json", result: { forecast: "sunny" } }),
],
}),
)
expect(prepared.body.input).toEqual([
{ type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
{ type: "function_call_output", call_id: "call_1", output: '{"forecast":"sunny"}' },
])
}),
)
it.effect("emits only missing function arguments from the arguments done event", () =>
Effect.gen(function* () {
const body = sseEvents(
@@ -4017,7 +3936,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("uses completed response output when item completion and its terminal item id are missing", () =>
it.effect("uses completed response output when output item completion is missing", () =>
Effect.gen(function* () {
const body = sseEvents(
{
@@ -4032,6 +3951,7 @@ describe("OpenAI Responses route", () => {
output: [
{
type: "function_call",
id: "fc_item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
@@ -4053,37 +3973,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("reconciles an item-id-less pending function call from completed response output", () =>
Effect.gen(function* () {
const item = { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" }
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 0, item },
{
type: "response.function_call_arguments.delta",
output_index: 0,
item_id: "opaque_delta",
delta: '{"query":"partial',
},
{
type: "response.completed",
response: { id: "resp_1", output: [{ ...item, arguments: '{"query":"complete"}' }] },
},
),
),
),
)
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "complete" } }),
])
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
expect(response.events.filter(LLMEvent.is.toolInputEnd)).toHaveLength(1)
}),
)
it.effect("lets completed response output override arguments done", () =>
Effect.gen(function* () {
const body = sseEvents(
@@ -9,30 +9,42 @@ const sessionB = session("ses_tab_b", "Tab B session")
const sessionC = session("ses_tab_c", "Tab C session")
const unresolvedSessionID = "ses_tab_unresolved"
test("new session tab hugs its content", async ({ page }) => {
test("new session tab matches neighboring session widths", async ({ page }, testInfo) => {
await mockServer(page)
await page.addInitScript(
({ server, sessionID, directory }) => {
({ server, sessionA, sessionB, directory }) => {
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
{ type: "session", server, sessionId: sessionID },
{ type: "session", server, sessionId: sessionA },
{ type: "draft", server, directory, draftID: "draft_tab_width" },
{ type: "session", server, sessionId: sessionB },
]),
)
},
{ server, sessionID: sessionA.id, directory: sessionA.directory },
{ server, sessionA: sessionA.id, sessionB: sessionB.id, directory: sessionA.directory },
)
const href = `/server/${base64Encode(server)}/session/${sessionA.id}`
await page.goto(href)
const sessionTab = page.locator(`[data-titlebar-tab-slot]:has(a[href="${href}"])`)
const draftTab = page.locator('[data-titlebar-tab-slot]:has(a[href^="/new-session?draftId="])')
await expect(draftTab).toContainText("New session")
const width = (await draftTab.boundingBox())?.width ?? 0
expect(width).toBeGreaterThan(100)
expect(width).toBeLessThan((await sessionTab.boundingBox())?.width ?? 0)
const tabs = page.locator("[data-titlebar-tab-slot]")
await expect(tabs.locator("[data-titlebar-tab-title]")).toHaveText([sessionA.title, "New session", sessionB.title])
await testInfo.attach("new-session-between-tabs", {
body: await page.locator('[data-slot="titlebar-v2"]').screenshot(),
contentType: "image/png",
})
for (const width of [1280, 800]) {
await page.setViewportSize({ width, height: 720 })
await expect
.poll(() =>
tabs.evaluateAll((tabs) => {
const widths = tabs.map((tab) => tab.getBoundingClientRect().width)
return Math.max(...widths) - Math.min(...widths)
}),
)
.toBeLessThan(1)
}
})
test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => {
@@ -280,9 +292,7 @@ test("appearance experimental settings control vertical tab details", async ({ p
await page.reload()
const href = `/server/${base64Encode(server)}/session/${sessionA.id}`
await page.getByRole("button", { name: "Tabs", exact: true }).click()
await expect(page.locator('[data-slot="mobile-tabs-drawer"] [data-slot="tab-project"]')).toHaveText([
"tab-project",
])
await expect(page.locator('[data-slot="mobile-tabs-drawer"] [data-slot="tab-project"]')).toHaveText(["tab-project"])
await expect(
page
.locator('[data-slot="mobile-tabs-drawer"]')
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { AgentListOutput, ModelListOutput, ProviderListOutput } from "@opencode-ai/client/promise"
import { directoryKey, normalizeAgentList, normalizeProviderList } from "./utils"
import type { AgentListOutput, ModelListOutput, Project, ProviderListOutput } from "@opencode-ai/client/promise"
import { directoryKey, normalizeAgentList, normalizeProjectInfo, normalizeProviderList } from "./utils"
describe("normalizeAgentList", () => {
test("adapts current agents to the app agent shape", () => {
@@ -85,6 +85,15 @@ describe("normalizeProviderList", () => {
})
})
describe("normalizeProjectInfo", () => {
test("keeps the project VCS backend", () => {
const project = { id: "prj", canonical: "/repo", time: { created: 1, updated: 1 }, sandboxes: [] }
expect(normalizeProjectInfo({ ...project, vcs: "git" } as Project).vcs).toBe("git")
expect(normalizeProjectInfo({ ...project, vcs: "hg" } as Project).vcs).toBe("hg")
expect(normalizeProjectInfo(project as Project).vcs).toBeUndefined()
})
})
describe("directoryKey", () => {
test("normalizes slashes", () => {
expect(String(directoryKey("C:\\Repos\\sst\\opencode"))).toBe("C:/Repos/sst/opencode")
@@ -135,6 +135,5 @@ export function normalizeProjectInfo(project: Project | CurrentProject): Project
...project,
worktree,
worktrees: "worktrees" in project ? project.worktrees : [{ directory: worktree }],
vcs: project.vcs === "git" ? "git" : undefined,
}
}
-2
View File
@@ -230,8 +230,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
children.mark(key)
if (event.type === "config.updated" || event.type === "agent.updated") queue.push(key)
if (event.type === "worktree.updated") void bootstrap.refetch()
if (event.type === "reference.updated" && children.active(key))
void data.location.reference.sync({ directory: key }).catch(() => undefined)
})
onCleanup(unsub)
+5 -5
View File
@@ -55,9 +55,9 @@ export function createSessionReview(input: {
const options = createMemo<ChangeMode[]>(() => {
const list: ChangeMode[] = []
const project = input.session.project()
if (project?.vcs === "git") list.push("git")
if (project?.vcs) list.push("git")
if (
project?.vcs === "git" &&
project?.vcs &&
vcs()?.branch.current &&
vcs()?.branch.default &&
vcs()?.branch.current !== vcs()?.branch.default
@@ -94,7 +94,7 @@ export function createSessionReview(input: {
const value = vcsMode()
return {
queryKey: [...vcsKey(), value] as const,
enabled: server.connection.status() === "connected" && wantsReview() && input.session.project()?.vcs === "git",
enabled: server.connection.status() === "connected" && wantsReview() && !!input.session.project()?.vcs,
refetchOnMount: "always" as const,
refetchOnWindowFocus: true,
queryFn: value
@@ -110,7 +110,7 @@ export function createSessionReview(input: {
})
const detailsQuery = createQuery(() => ({
queryKey: [server.scope, "session-details", input.session.workspace.directory()] as const,
enabled: state.detailsOpen && server.connection.status() === "connected" && input.session.project()?.vcs === "git",
enabled: state.detailsOpen && server.connection.status() === "connected" && !!input.session.project()?.vcs,
queryFn: () =>
server.api.vcs
.diff({ location: { directory: input.session.workspace.directory() }, mode: "working" })
@@ -413,7 +413,7 @@ export function createSessionReview(input: {
tab: () => state.mobileTab,
},
mode,
noGit: createMemo(() => !!input.session.project() && input.session.project()?.vcs !== "git"),
noGit: createMemo(() => !!input.session.project() && !input.session.project()?.vcs),
open,
openFile,
options,
+1 -1
View File
@@ -165,7 +165,7 @@ export function SessionScreen(props: { session: SessionModel }) {
baseBranch={
session.shared.data.location.vcs.info({ directory: project().worktree })?.branch.current
}
diffs={project().vcs === "git" ? review.details.diffs() : []}
diffs={project().vcs ? review.details.diffs() : []}
sessionID={session.identity.params.id ?? ""}
moveEligible={composer.workspaceMoveEligible()}
moveDismissed={store.mobileMoveDismissed}
+4 -9
View File
@@ -395,11 +395,8 @@ export function DraftTabItem(props: {
data-active={props.active}
data-dragging={props.dragging}
data-state={props.active || props.pressed ? "pressed" : undefined}
class="group relative flex h-7 min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] px-1.5 whitespace-nowrap"
classList={{
invisible: props.hidden,
"w-full [container-type:inline-size]": props.orientation === "vertical",
}}
class="group relative flex h-7 w-full min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] px-1.5 [container-type:inline-size] whitespace-nowrap"
classList={{ invisible: props.hidden }}
onMouseDown={(event) => {
if (event.button !== MIDDLE_MOUSE_BUTTON) return
event.preventDefault()
@@ -432,16 +429,14 @@ export function DraftTabItem(props: {
if (props.suppressNavigation) return
props.onNavigate()
}}
class="flex h-full min-w-0 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]"
classList={{ "flex-1": props.orientation === "vertical", "flex-none pe-10": props.orientation !== "vertical" }}
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]"
>
<span class="flex size-4 shrink-0 items-center justify-center">
<Icon name="edit" />
</span>
<span
data-titlebar-tab-title
class="min-w-0 overflow-hidden text-clip whitespace-nowrap outline-none leading-4"
classList={{ "flex-1": props.orientation === "vertical", "flex-none": props.orientation !== "vertical" }}
class="min-w-0 flex-1 overflow-hidden text-clip whitespace-nowrap outline-none leading-4"
>
{props.title}
</span>
@@ -211,7 +211,7 @@ function DraftTabSlot(props: {
data-orientation={props.orientation}
class="relative flex"
classList={{
"w-max min-w-7 max-w-56 shrink-0": props.orientation === "horizontal",
"w-56 min-w-7 max-w-56 flex-shrink": props.orientation === "horizontal",
"w-full shrink-0": props.orientation === "vertical",
}}
>
+4 -1
View File
@@ -128,7 +128,9 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh
external: ["node-gyp"],
format: "esm",
minify: true,
bytecode: true,
// Bun 1.4.0 cross-compiled bytecode can crash on Windows (oven-sh/bun#40270).
// Re-enable after both the builder and embedded runtime move to Bun 1.4.1.
bytecode: false,
sourcemap: Script.channel === "dev" || Script.channel === "local" ? "inline" : "none",
splitting: true,
compile: {
@@ -146,6 +148,7 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh
OPENCODE_VERSION: `'${Script.version}'`,
OPENCODE_CLI_NAME: `'${binary}'`,
OPENCODE_CHANNEL: `'${Script.channel}'`,
OPENCODE_ARTIFACT: `'cli'`,
OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "undefined",
// FFF_LIBC selects the fff native lib variant: "musl" or "gnu".
FFF_LIBC: item.os === "linux" ? `'${item.abi ?? "gnu"}'` : "undefined",
+16 -8
View File
@@ -23,7 +23,13 @@ async function publish(dir: string, name: string, version: string) {
}
}
async function publishDistribution(input: { root: string; name: string; binary: string; packagePrefix: string }) {
async function publishDistribution(input: {
root: string
name: string
binary: string
packagePrefix: string
artifact: string
}) {
const binaries: Record<string, string> = {}
for (const filepath of new Bun.Glob("*/package.json").scanSync({ cwd: input.root })) {
const item = await Bun.file(`${input.root}/${filepath}`).json()
@@ -72,6 +78,13 @@ async function publishDistribution(input: { root: string; name: string; binary:
),
)
await publish(`${input.root}/${input.name}`, input.name, version)
await UpdateArtifact.publish({
channel: Script.channel,
name: input.artifact,
distribution: "npm",
version,
metadata: { package: input.name },
})
}
await publishDistribution({
@@ -79,6 +92,7 @@ await publishDistribution({
name: pkg.name,
binary: "opencode2",
packagePrefix: "@opencode-ai/cli-",
artifact: "cli",
})
if (existsSync("./dist/node")) {
await publishDistribution({
@@ -86,12 +100,6 @@ if (existsSync("./dist/node")) {
name: "opencode-node",
binary: "opencode2-node",
packagePrefix: "@opencode-ai/cli-node-",
artifact: "cli-node",
})
}
await UpdateArtifact.publish({
channel: Script.channel,
name: "cli",
distribution: "npm",
version: Script.version,
metadata: {},
})
+5 -1
View File
@@ -93,7 +93,11 @@ try {
}
const output = await Promise.all(errors)
await fs.rm(root, { recursive: true, force: true })
// Windows can retain directory handles briefly after the service processes exit.
await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }).catch((cause: unknown) => {
console.error("Failed to remove service smoke-test directory", cause)
failure ??= cause
})
if (failure)
throw new Error(output.filter(Boolean).join("\n") || "Compiled service lifecycle smoke test failed", {
cause: failure,
@@ -89,12 +89,7 @@ export default Runtime.handler(Commands, (input) =>
}
: undefined,
packages: {
resolve: (spec, install = true) =>
runPromise(
(install ? npm.add(spec, { subpaths: ["tui"] }) : npm.resolve(spec, { subpaths: ["tui"] })).pipe(
Effect.map((result) => result.entrypoint),
),
),
prepare: (spec, install = true) => runPromise(install ? npm.add(spec) : npm.resolve(spec)),
},
environment: requestedServer === undefined ? Env.session() : undefined,
terminalHandoff: () => preflight.finish(),
@@ -5,6 +5,7 @@ import { Effect } from "effect"
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
import { Global } from "@opencode-ai/util/global"
import { Npm } from "@opencode-ai/util/npm"
import { Host } from "@opencode-ai/plugin/host"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { resolveConfigPath } from "../mcp/add"
@@ -16,9 +17,9 @@ export default Runtime.handler(
if (!(yield* Effect.promise(() => Npm.isInstallablePackage(input.package))))
return yield* Effect.fail(new Error("Plugin target must be an npm registry package or Git package specifier"))
const npm = yield* Npm.Service
const installed = yield* npm.add(input.package, { subpaths: ["server", ""] })
const tui = yield* npm.resolve(input.package, { subpaths: ["tui"] })
const target = configurationTarget(installed.entrypoint, tui.entrypoint)
const installed = yield* npm.add(input.package)
const entrypoints = Host.resolve(installed)
const target = configurationTarget(entrypoints.server, entrypoints.tui)
if (!target)
return yield* Effect.fail(new Error(`Plugin package has no server or TUI entrypoint: ${input.package}`))
@@ -62,7 +62,7 @@ export const inspect = Effect.fn("cli.plugin.inspect")(function* (selected?: str
tuiTargets,
(target) =>
Effect.gen(function* () {
const installed = yield* npm.resolve(target, { subpaths: ["tui"] })
const installed = yield* npm.resolve(target)
const outdated = yield* npm.check(target).pipe(Effect.exit)
return {
runtime: "TUI" as const,
@@ -9,8 +9,9 @@ import { ServiceConfig } from "../../../services/service-config"
import { Config } from "../../../config"
import { Global } from "@opencode-ai/util/global"
import { Npm } from "@opencode-ai/util/npm"
import { Host } from "@opencode-ai/plugin/host"
import { fileURLToPath } from "node:url"
import { discoverTuiPlugins, localPluginDirectories, localSource } from "@opencode-ai/tui/plugin/discovery"
import { discoverPluginTargets, localPluginDirectories, localSource } from "@opencode-ai/tui/plugin/discovery"
export default Runtime.handler(
Commands.commands.plugin.commands.list,
@@ -22,27 +23,27 @@ export default Runtime.handler(
const global = yield* Global.Service
const info = yield* config.get()
const discovered = yield* Effect.promise(() =>
localPluginDirectories(process.cwd(), global.config).then(discoverTuiPlugins),
localPluginDirectories(process.cwd(), global.config).then(discoverPluginTargets),
)
const npm = yield* Npm.Service
const configured = yield* Effect.forEach(info.plugins ?? [], (entry) =>
const configured = yield* Effect.forEach([...(info.plugins ?? []), ...discovered], (entry) =>
Effect.gen(function* () {
const target = typeof entry === "string" ? entry : entry.package
if (target.startsWith("-") || target === "*" || target.endsWith(".*") || target.startsWith("opencode."))
return []
const local = localSource(target, path.dirname(config.path))
if (local) return [{ target: fileURLToPath(local), version: "local" }]
if (local) {
const directory = fileURLToPath(local)
const entrypoints = Host.resolve({ directory })
return entrypoints.tui ? [{ target: directory, version: "local" }] : []
}
if (!(yield* Effect.promise(() => Npm.isInstallablePackage(target)))) return []
const installed = yield* npm.resolve(target, { subpaths: ["tui"] })
if (!installed.entrypoint) return []
const installed = yield* npm.resolve(target)
if (!Host.resolve(installed).tui) return []
return [{ target, version: installed.version }]
}),
)
const output = format(
response.data,
[...configured.flat(), ...discovered.map((target) => ({ target, version: "local" }))],
input.builtin,
)
const output = format(response.data, configured.flat(), input.builtin)
if (!output) {
process.stdout.write("No plugins found" + EOL)
return
@@ -81,9 +82,7 @@ export function format(
? plugin.source.target === item.target
: plugin.source.type === "local" &&
(plugin.source.path === item.target ||
(plugin.features.tui &&
path.dirname(plugin.source.path) ===
(item.version === "local" && path.extname(item.target) ? path.dirname(item.target) : item.target))),
(plugin.features.tui && path.dirname(plugin.source.path) === item.target)),
),
)
.filter((plugin, index, all) => all.findIndex((candidate) => candidate.target === plugin.target) === index)
@@ -26,7 +26,7 @@ export default Runtime.handler(
(item) =>
(item.runtime === "Server"
? Effect.promise(() => result.client.plugin.update({ location: result.location, targets: [item.target] }))
: npm.update(item.target, { subpaths: ["tui"] }).pipe(Effect.asVoid)
: npm.update(item.target).pipe(Effect.asVoid)
).pipe(
Effect.exit,
Effect.map((result) => ({ item, result })),
+39 -20
View File
@@ -2,21 +2,16 @@ import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
import { OpenCode } from "@opencode-ai/client"
import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
import { OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
import { Context, Duration, Effect, FileSystem, Layer, Ref, Schedule, Semaphore, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { parse, type ParseError } from "jsonc-parser"
import path from "node:path"
import { action, parseReleaseVersion, type Action, type Policy } from "./updater-action"
declare const OPENCODE_CLI_NAME: string | undefined
export const methods = ["curl", "npm", "pnpm", "bun", "yarn"] as const
export type Method = (typeof methods)[number]
const packageName =
typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-node" ? "opencode-node" : "@opencode-ai/cli"
export interface Interface {
readonly check: () => Effect.Effect<void>
readonly monitor: (input: {
@@ -180,6 +175,15 @@ const make = Effect.gen(function* () {
const global = yield* Global.Service
const appProcess = yield* AppProcess.Service
const channel = OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
const installedPackage = yield* Effect.gen(function* () {
const executable = yield* fs.realPath(process.execPath)
const directory = path.dirname(path.dirname(executable))
const manifest: { name: string; bin?: Record<string, string> } = yield* fs
.readFileString(path.join(directory, "package.json"))
.pipe(Effect.flatMap((text) => Effect.try(() => JSON.parse(text))))
if (Object.values(manifest.bin ?? {}).some((bin) => path.resolve(directory, bin) === executable))
return manifest.name
}).pipe(Effect.orElseSucceed(() => undefined))
const readPolicy = Effect.fnUntraced(function* () {
const values = yield* Effect.forEach(["config.json", "opencode.json", "opencode.jsonc"], (name) =>
@@ -216,10 +220,11 @@ const make = Effect.gen(function* () {
process.platform === "win32" ? "opencode2.exe" : "opencode2",
)
if (path.resolve(process.execPath) === path.resolve(binary)) return "curl"
if (!installedPackage) return
const checks: ReadonlyArray<{ method: Method; command: string[] }> = [
{ method: "npm", command: ["npm", "list", "-g", "--depth=0", packageName] },
{ method: "pnpm", command: ["pnpm", "list", "-g", "--depth=0", packageName] },
{ method: "npm", command: ["npm", "list", "-g", "--depth=0", installedPackage] },
{ method: "pnpm", command: ["pnpm", "list", "-g", "--depth=0", installedPackage] },
{ method: "bun", command: ["bun", "pm", "ls", "-g"] },
{ method: "yarn", command: ["yarn", "global", "list"] },
]
@@ -228,35 +233,49 @@ const make = Effect.gen(function* () {
(check) => run(check.command).pipe(Effect.map((result) => ({ check, result }))),
{ concurrency: "unbounded" },
)
return results.find((result) => result.result.stdout.includes(packageName))?.check.method
return results.find((result) => result.result.stdout.includes(installedPackage))?.check.method
})
const latest = Effect.fnUntraced(function* () {
const release = Effect.fnUntraced(function* () {
const response = yield* Effect.tryPromise({
try: () =>
fetch(`https://update.opencode.ai/api/${encodeURIComponent(channel)}/cli/npm`, {
headers: { "User-Agent": `opencode/${OPENCODE_VERSION}` },
signal: AbortSignal.timeout(10_000),
}),
fetch(
`https://update.opencode.ai/api/${encodeURIComponent(channel)}/${encodeURIComponent(OPENCODE_ARTIFACT)}/npm`,
{
headers: { "User-Agent": `opencode/${OPENCODE_VERSION}` },
signal: AbortSignal.timeout(10_000),
},
),
catch: (cause) => new Error("Failed to check for updates", { cause }),
})
if (!response.ok) return yield* Effect.fail(new Error(`Update check failed with status ${response.status}`))
const data = yield* Effect.tryPromise({
const data: { version: string; metadata?: { package?: string } } = yield* Effect.tryPromise({
try: () => response.json(),
catch: (cause) => new Error("Failed to read update information", { cause }),
})
if (typeof data !== "object" || data === null || !("version" in data) || typeof data.version !== "string") {
return yield* Effect.fail(new Error("Update information did not include a version"))
}
return data.version
if (!data.metadata?.package) return yield* Effect.fail(new Error("Update information did not include a package"))
return { package: data.metadata.package, version: data.version }
})
const latest = () => release().pipe(Effect.map((data) => data.version))
const upgrade = Effect.fnUntraced(function* (method: Method, input: string) {
if (!parseReleaseVersion(input)) return yield* Effect.fail(new Error(`Invalid version: ${input}`))
const version = input.trim().replace(/^v/, "")
const packageName = (yield* release()).package
const target = `${packageName}@${version}`
if (installedPackage && packageName !== installedPackage && (method === "pnpm" || method === "yarn")) {
return yield* Effect.fail(new Error(`Reinstall ${target} with ${method} to migrate from ${installedPackage}.`))
}
const commands: Record<Exclude<Method, "bun" | "curl">, string[]> = {
npm: ["npm", "install", "--global", target],
// Keep the old package: uninstalling it can unlink the replacement command.
npm: [
"npm",
"install",
"--global",
...(installedPackage && packageName !== installedPackage ? ["--force"] : []),
target,
],
pnpm: ["pnpm", "add", "--global", `--allow-build=${packageName}`, target],
yarn: ["yarn", "global", "add", target],
}
+3 -1
View File
@@ -1,8 +1,10 @@
declare const OPENCODE_VERSION: string
declare const OPENCODE_CHANNEL: string
declare const OPENCODE_ARTIFACT: string
const version = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
const channel = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local"
const artifact = typeof OPENCODE_ARTIFACT === "string" ? OPENCODE_ARTIFACT : "cli"
export { version as OPENCODE_VERSION, channel as OPENCODE_CHANNEL }
export { version as OPENCODE_VERSION, channel as OPENCODE_CHANNEL, artifact as OPENCODE_ARTIFACT }
export const OPENCODE_LOCAL = channel === "local"
+29 -4
View File
@@ -1,7 +1,7 @@
import { NodeServices } from "@effect/platform-node"
import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
import { expect, test } from "bun:test"
import { expect, spyOn, test } from "bun:test"
import { Effect, FileSystem, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { existsSync } from "node:fs"
@@ -17,11 +17,29 @@ function fixture(
respond: (command: ChildProcess.StandardCommand) => Partial<AppProcess.RunResult> & {
error?: AppProcess.AppProcessError
} = () => ({}),
name = "@opencode-ai/cli",
) {
return Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const root = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-updater-" })
const executable = path.join(root, "package", "bin", "opencode")
yield* fs.makeDirectory(path.dirname(executable), { recursive: true })
yield* fs.writeFileString(
path.join(root, "package", "package.json"),
JSON.stringify({ name, bin: { opencode: "bin/opencode" } }),
)
// The updater uses global fetch; scope this replacement to each install test.
yield* Effect.acquireRelease(
Effect.sync(() =>
spyOn(globalThis, "fetch").mockImplementation(
Object.assign(async () => Response.json({ version: "2.3.4", metadata: { package: name } }), {
preconnect: fetch.preconnect,
}),
),
),
(request) => Effect.sync(() => request.mockRestore()),
)
const global = Global.make({
home: path.join(root, "home"),
data: path.join(root, "data"),
@@ -37,6 +55,10 @@ function fixture(
const updater = yield* Updater.Service.pipe(
Effect.provide(Updater.layer),
Effect.provideService(Global.Service, global),
Effect.provideService(FileSystem.FileSystem, {
...fs,
realPath: (input) => (input === process.execPath ? Effect.succeed(executable) : fs.realPath(input)),
}),
Effect.provideService(
AppProcess.Service,
AppProcess.Service.of({
@@ -214,9 +236,12 @@ test("Node distribution honors the compile-time CLI name", async () => {
if (typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-node") {
it.live("Node distribution resolves the published npm package", () =>
Effect.gen(function* () {
const test = yield* fixture((command) => ({
stdout: Buffer.from(command.command === "npm" ? "opencode-node@2.3.4" : ""),
}))
const test = yield* fixture(
(command) => ({
stdout: Buffer.from(command.command === "npm" ? "opencode-node@2.3.4" : ""),
}),
"opencode-node",
)
expect(yield* test.updater.method()).toBe("npm")
yield* test.updater.upgrade("npm", "v2.3.4")
yield* test.updater.upgrade("pnpm", "v2.3.4")
+1
View File
@@ -268,6 +268,7 @@ export function mainConfig(input: NodeBuildInput): UserConfig {
OPENCODE_VERSION: JSON.stringify(input.version),
OPENCODE_CLI_NAME: JSON.stringify("opencode2-node"),
OPENCODE_CHANNEL: JSON.stringify(input.channel),
OPENCODE_ARTIFACT: JSON.stringify("cli-node"),
OPENCODE_LIBC: input.target.platform === "linux" ? JSON.stringify("glibc") : "undefined",
FFF_LIBC: input.target.platform === "linux" ? JSON.stringify("gnu") : "undefined",
"process.env.WS_NO_BUFFER_UTIL": JSON.stringify("1"),
@@ -1634,7 +1634,7 @@ export function make(options: ClientOptions) {
path: `/api/skill`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 500],
empty: false,
},
requestOptions,
+9 -10
View File
@@ -985,15 +985,6 @@ export type ReferenceUpdated = {
data: {}
}
export type PluginAdded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "plugin.added"
location?: LocationRef
data: { id: string }
}
export type PluginUpdated = {
id: string
created: number
@@ -2299,7 +2290,6 @@ export type V2Event =
| ReferenceUpdated
| PermissionAsked
| PermissionReplied
| PluginAdded
| PluginUpdated
| ProjectUpdated
| WorktreeUpdated
@@ -2498,6 +2488,15 @@ export type PermissionNotFoundError = {
export const isPermissionNotFoundError = (value: unknown): value is PermissionNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "PermissionNotFoundError"
export type PluginCallbackError = {
readonly _tag: "PluginCallbackError"
readonly pluginID: string
readonly operation: "skill.transform"
readonly message: string
}
export const isPluginCallbackError = (value: unknown): value is PluginCallbackError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "PluginCallbackError"
export type RpcError = {
readonly _tag: "RpcError"
readonly type: string
+220 -429
View File
@@ -83,10 +83,8 @@ type LocationData = {
agent?: AgentInfo[]
command?: CommandInfo[]
integration?: IntegrationInfo[]
mcp?: {
server?: McpServer[]
resource?: McpResource[]
}
mcpServer?: McpServer[]
mcpResource?: McpResource[]
model?: ModelInfo[]
provider?: ProviderInfo[]
reference?: ReferenceInfo[]
@@ -109,7 +107,6 @@ type Store = {
messageCursor: Record<string, string | undefined>
messageLoading: Record<string, boolean>
pending: Record<string, SessionInboxInfo[]>
input: Record<string, string[]>
permission: Record<string, PermissionRequest[]>
// Pending forms keyed by owner: a session ID or the temporary "global" elicitation sentinel.
form: Record<string, FormWithLocation[]>
@@ -125,8 +122,8 @@ export function locationKey(location: LocationRef) {
return JSON.stringify([location.directory, location.workspaceID])
}
function locationQuery(ref?: LocationRef) {
return ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined
function locationQuery(ref: LocationRef) {
return { directory: ref.directory, workspace: ref.workspaceID }
}
function formRequestOptions(sessionID: string, ref?: LocationRef) {
@@ -200,7 +197,6 @@ export function createData(config: CreateDataInput) {
messageCursor: {},
messageLoading: {},
pending: {},
input: {},
permission: {},
form: {},
},
@@ -233,13 +229,6 @@ export function createData(config: CreateDataInput) {
sessionID,
(store.session.pending[sessionID] ?? []).filter((item) => item.id !== inboxID),
)
if (store.session.input[sessionID]?.includes(inboxID))
setStore(
"session",
"input",
sessionID,
(store.session.input[sessionID] ?? []).filter((id) => id !== inboxID),
)
}
function removePermission(sessionID: string, requestID: string) {
@@ -337,8 +326,8 @@ export function createData(config: CreateDataInput) {
return request
}
// Upsert an admitted inbox item into pending, input, and (for user and
// synthetic items) the visible transcript. Used by the inbox.enqueued
// Upsert an admitted inbox item into pending and (for user and synthetic
// items) the visible transcript. Used by the inbox.enqueued
// handler and by optimistic admission; the upsert is what reconciles
// the durable echo with an optimistic placeholder — the durable payload and
// times replace the client's guess.
@@ -353,8 +342,6 @@ export function createData(config: CreateDataInput) {
at < 0 ? [...pending, item] : pending.map((entry, index) => (index === at ? item : entry)),
)
if (item.type === "compaction") return
const input = store.session.input[item.sessionID] ?? []
if (!input.includes(item.id)) setStore("session", "input", item.sessionID, [...input, item.id])
materializeInboxMessage(item)
})
}
@@ -403,15 +390,44 @@ export function createData(config: CreateDataInput) {
index.set(item.id, messages.length)
messages.push(item)
},
insert(sessionID: string, item: SessionMessageInfo) {
message.update(sessionID, (draft, index) => message.append(draft, index, item))
},
// Streaming events target one assistant message and, within it, the latest part of a kind.
// A missing target means the row was never loaded or was evicted; the event is dropped.
editAssistant(sessionID: string, messageID: string, fn: (assistant: SessionMessageAssistant) => void) {
message.update(sessionID, (draft, index) => {
const position = index.get(messageID)
const item = position === undefined ? undefined : draft[position]
if (item?.type === "assistant") fn(item)
})
},
editTool(sessionID: string, messageID: string, toolID: string, fn: (tool: SessionMessageAssistantTool) => void) {
message.editAssistant(sessionID, messageID, (assistant) => {
const tool = assistant.content.findLast(
(item): item is SessionMessageAssistantTool => item.type === "tool" && item.id === toolID,
)
if (tool) fn(tool)
})
},
editText(sessionID: string, messageID: string, fn: (text: SessionMessageAssistantText) => void) {
message.editAssistant(sessionID, messageID, (assistant) => {
const text = assistant.content.findLast((item): item is SessionMessageAssistantText => item.type === "text")
if (text) fn(text)
})
},
editReasoning(sessionID: string, messageID: string, fn: (reasoning: SessionMessageAssistantReasoning) => void) {
message.editAssistant(sessionID, messageID, (assistant) => {
const reasoning = assistant.content.findLast(
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && !item.time?.completed,
)
if (reasoning) fn(reasoning)
})
},
activeAssistant(messages: SessionMessageInfo[]) {
const item = messages.findLast((item) => item.type === "assistant" && !item.time.completed)
return item?.type === "assistant" ? item : undefined
},
assistant(messages: SessionMessageInfo[], index: Map<string, number>, messageID: string) {
const position = index.get(messageID)
const item = position === undefined ? undefined : messages[position]
return item?.type === "assistant" ? item : undefined
},
shell(messages: SessionMessageInfo[], shellID: string) {
const item = messages.findLast((item) => item.type === "shell" && item.shellID === shellID)
return item?.type === "shell" ? item : undefined
@@ -420,19 +436,6 @@ export function createData(config: CreateDataInput) {
const item = messages.findLast((item) => item.type === "compaction" && item.status === "running")
return item?.type === "compaction" ? item : undefined
},
latestTool(assistant: SessionMessageAssistant | undefined, id?: string) {
return assistant?.content.findLast(
(item): item is SessionMessageAssistantTool => item.type === "tool" && (id === undefined || item.id === id),
)
},
latestText(assistant: SessionMessageAssistant | undefined) {
return assistant?.content.findLast((item): item is SessionMessageAssistantText => item.type === "text")
},
latestReasoning(assistant: SessionMessageAssistant | undefined) {
return assistant?.content.findLast(
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && !item.time?.completed,
)
},
reindex(messages: SessionMessageInfo[], index: Map<string, number>, start: number) {
for (let position = start; position < messages.length; position++) {
const item = messages[position]
@@ -510,12 +513,8 @@ export function createData(config: CreateDataInput) {
delete draft.messageCursor[sessionID]
delete draft.messageLoading[sessionID]
delete draft.pending[sessionID]
delete draft.input[sessionID]
if (messages.length) draft.message[sessionID] = messages
if (pending.length) {
draft.pending[sessionID] = pending
draft.input[sessionID] = pending.filter((item) => item.type !== "compaction").map((item) => item.id)
}
if (pending.length) draft.pending[sessionID] = pending
}),
)
}
@@ -539,7 +538,6 @@ export function createData(config: CreateDataInput) {
delete draft.messageCursor[sessionID]
delete draft.messageLoading[sessionID]
delete draft.pending[sessionID]
delete draft.input[sessionID]
delete draft.permission[sessionID]
delete draft.form[sessionID]
for (const [rootID, family] of Object.entries(draft.family)) {
@@ -611,14 +609,12 @@ export function createData(config: CreateDataInput) {
const previous = store.session.info[event.data.sessionID]?.agent
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "agent", event.data.agent)
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: messageIDFromEvent(event.id),
type: "agent-switched",
agent: event.data.agent,
previous,
time: { created: event.created },
})
message.insert(event.data.sessionID, {
id: messageIDFromEvent(event.id),
type: "agent-switched",
agent: event.data.agent,
previous,
time: { created: event.created },
})
return
}
@@ -626,13 +622,11 @@ export function createData(config: CreateDataInput) {
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "model", event.data.model)
if (!store.session.message[event.data.sessionID]) return
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: messageIDFromEvent(event.id),
type: "model-switched",
model: event.data.model,
time: { created: event.created },
})
message.insert(event.data.sessionID, {
id: messageIDFromEvent(event.id),
type: "model-switched",
model: event.data.model,
time: { created: event.created },
})
void api()
.session.message({ sessionID: event.data.sessionID, messageID: messageIDFromEvent(event.id) })
@@ -667,16 +661,14 @@ export function createData(config: CreateDataInput) {
setStore("session", "info", event.data.sessionID, "location", event.data.location)
if (event.data.projectID) setStore("session", "info", event.data.sessionID, "projectID", event.data.projectID)
setStore("session", "info", event.data.sessionID, "subpath", event.data.subpath)
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: messageIDFromEvent(event.id),
type: "location-switched",
location: event.data.location,
projectID: event.data.projectID,
subpath: event.data.subpath,
previous,
time: { created: event.created },
})
message.insert(event.data.sessionID, {
id: messageIDFromEvent(event.id),
type: "location-switched",
location: event.data.location,
projectID: event.data.projectID,
subpath: event.data.subpath,
previous,
time: { created: event.created },
})
}
return
@@ -706,7 +698,7 @@ export function createData(config: CreateDataInput) {
return
}
case "session.inbox.delivered": {
const admitted = store.session.input[event.data.sessionID]?.includes(event.data.inboxID) ?? false
const admitted = result.session.input.has(event.data.sessionID, event.data.inboxID)
removePending(event.data.sessionID, event.data.inboxID)
message.update(event.data.sessionID, (draft, index) => {
const position = index.get(event.data.inboxID)
@@ -750,42 +742,36 @@ export function createData(config: CreateDataInput) {
// and produce no transcript message.
const updateText = event.data.text
if (updateText === undefined) return
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: messageIDFromEvent(event.id),
type: "system",
text: updateText,
description: `Instructions updated: ${Object.keys(event.data.delta).join(", ")}`,
metadata: event.metadata,
time: { created: event.created },
})
message.insert(event.data.sessionID, {
id: messageIDFromEvent(event.id),
type: "system",
text: updateText,
description: `Instructions updated: ${Object.keys(event.data.delta).join(", ")}`,
metadata: event.metadata,
time: { created: event.created },
})
return
case "session.synthetic":
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: messageIDFromEvent(event.id),
type: "synthetic",
text: event.data.text,
description: event.data.description,
metadata: event.data.metadata,
time: { created: event.created },
})
message.insert(event.data.sessionID, {
id: messageIDFromEvent(event.id),
type: "synthetic",
text: event.data.text,
description: event.data.description,
metadata: event.data.metadata,
time: { created: event.created },
})
return
case "session.shell.started":
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: messageIDFromEvent(event.id),
type: "shell",
shellID: event.data.shell.id,
command: event.data.shell.command,
status: event.data.shell.status,
exit: event.data.shell.exit,
metadata:
event.data.shell.metadata.background === true ? { ...event.metadata, background: true } : event.metadata,
time: { created: event.created },
})
message.insert(event.data.sessionID, {
id: messageIDFromEvent(event.id),
type: "shell",
shellID: event.data.shell.id,
command: event.data.shell.command,
status: event.data.shell.status,
exit: event.data.shell.exit,
metadata:
event.data.shell.metadata.background === true ? { ...event.metadata, background: true } : event.metadata,
time: { created: event.created },
})
return
case "session.shell.ended":
@@ -800,9 +786,8 @@ export function createData(config: CreateDataInput) {
return
case "session.message.content.updated": {
if (store.session.message[event.data.sessionID])
message.update(event.data.sessionID, (draft, index) => {
const assistant = message.assistant(draft, index, event.data.messageID)
if (assistant) assistant.content = [...event.data.content]
message.editAssistant(event.data.sessionID, event.data.messageID, (assistant) => {
assistant.content = [...event.data.content]
})
if (!sync.pending(`session.message:${event.data.sessionID}`)) return
result.session.message.invalidate(event.data.sessionID)
@@ -844,65 +829,54 @@ export function createData(config: CreateDataInput) {
})
return
case "session.step.streamed":
message.update(event.data.sessionID, (draft, index) => {
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
if (currentAssistant) currentAssistant.time.streamed = event.created
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
assistant.time.streamed = event.created
})
return
case "session.step.ended": {
message.update(event.data.sessionID, (draft, index) => {
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
if (!currentAssistant) return
currentAssistant.time.completed = event.created
currentAssistant.finish = event.data.finish
currentAssistant.rawFinish = event.data.rawFinish
currentAssistant.providerState = event.data.providerState
currentAssistant.cost = event.data.cost
currentAssistant.tokens = event.data.tokens
if (event.data.snapshot)
currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.data.snapshot }
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
assistant.time.completed = event.created
assistant.finish = event.data.finish
assistant.rawFinish = event.data.rawFinish
assistant.providerState = event.data.providerState
assistant.cost = event.data.cost
assistant.tokens = event.data.tokens
if (event.data.snapshot) assistant.snapshot = { ...assistant.snapshot, end: event.data.snapshot }
})
return
}
case "session.step.failed":
message.update(event.data.sessionID, (draft, index) => {
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
if (!currentAssistant) return
currentAssistant.time.completed = event.created
currentAssistant.finish = event.data.finish ?? "error"
currentAssistant.rawFinish = event.data.rawFinish
currentAssistant.providerState = event.data.providerState
currentAssistant.error = event.data.error
currentAssistant.retry = undefined
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
assistant.time.completed = event.created
assistant.finish = event.data.finish ?? "error"
assistant.rawFinish = event.data.rawFinish
assistant.providerState = event.data.providerState
assistant.error = event.data.error
assistant.retry = undefined
if (event.data.cost !== undefined && event.data.tokens !== undefined) {
currentAssistant.cost = event.data.cost
currentAssistant.tokens = event.data.tokens
assistant.cost = event.data.cost
assistant.tokens = event.data.tokens
}
})
return
case "session.text.started":
message.update(event.data.sessionID, (draft, index) => {
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
type: "text",
text: "",
})
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
assistant.content.push({ type: "text", text: "" })
})
return
case "session.text.delta":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID))
if (match) match.text += event.data.delta
message.editText(event.data.sessionID, event.data.assistantMessageID, (text) => {
text.text += event.data.delta
})
return
case "session.text.ended":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID))
if (match) match.text = event.data.text
message.editText(event.data.sessionID, event.data.assistantMessageID, (text) => {
text.text = event.data.text
})
return
case "session.tool.input.started":
message.update(event.data.sessionID, (draft, index) => {
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
assistant.content.push({
type: "tool",
id: event.data.id,
name: event.data.name,
@@ -912,86 +886,60 @@ export function createData(config: CreateDataInput) {
})
return
case "session.tool.input.delta":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.id,
)
if (match?.state.status === "streaming") match.state.input += event.data.delta
message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
if (tool.state.status === "streaming") tool.state.input += event.data.delta
})
return
case "session.tool.input.ended":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.id,
)
if (match?.state.status === "streaming") match.state.input = event.data.text
message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
if (tool.state.status === "streaming") tool.state.input = event.data.text
})
return
case "session.tool.called":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.id,
)
if (!match) return
match.time.ran = event.created
match.executed = event.data.executed
match.providerState = event.data.state
match.state = { status: "running", input: event.data.input, metadata: {} }
message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
tool.time.ran = event.created
tool.executed = event.data.executed
tool.providerState = event.data.state
tool.state = { status: "running", input: event.data.input, metadata: {} }
})
return
case "session.tool.progress":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.id,
)
if (match?.state.status !== "running") return
match.state.metadata = event.data.metadata
message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
if (tool.state.status === "running") tool.state.metadata = event.data.metadata
})
return
case "session.tool.success":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.id,
)
if (match?.state.status !== "running") return
match.state = {
message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
if (tool.state.status !== "running") return
tool.state = {
status: "completed",
input: match.state.input,
input: tool.state.input,
metadata: event.data.metadata,
content: [...event.data.content],
}
match.executed = event.data.executed || match.executed === true
match.providerResultState = event.data.resultState
match.time.completed = event.created
tool.executed = event.data.executed || tool.executed === true
tool.providerResultState = event.data.resultState
tool.time.completed = event.created
})
return
case "session.tool.failed":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.id,
)
if (!match || (match.state.status !== "streaming" && match.state.status !== "running")) return
match.state = {
message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
if (tool.state.status !== "streaming" && tool.state.status !== "running") return
tool.state = {
status: "error",
error: event.data.error,
input: typeof match.state.input === "string" ? {} : match.state.input,
input: typeof tool.state.input === "string" ? {} : tool.state.input,
metadata: event.data.metadata,
content: event.data.content,
}
match.executed = event.data.executed || match.executed === true
match.providerResultState = event.data.resultState
match.time.completed = event.created
tool.executed = event.data.executed || tool.executed === true
tool.providerResultState = event.data.resultState
tool.time.completed = event.created
})
return
case "session.reasoning.started":
message.update(event.data.sessionID, (draft, index) => {
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
assistant.content.push({
type: "reasoning",
text: "",
state: event.data.state,
@@ -1000,30 +948,20 @@ export function createData(config: CreateDataInput) {
})
return
case "session.reasoning.delta":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestReasoning(message.assistant(draft, index, event.data.assistantMessageID))
if (match) match.text += event.data.delta
message.editReasoning(event.data.sessionID, event.data.assistantMessageID, (reasoning) => {
reasoning.text += event.data.delta
})
return
case "session.reasoning.ended":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestReasoning(message.assistant(draft, index, event.data.assistantMessageID))
if (match) {
match.text = event.data.text
match.time = { created: match.time?.created ?? event.created, completed: event.created }
if (event.data.state !== undefined) match.state = event.data.state
}
message.editReasoning(event.data.sessionID, event.data.assistantMessageID, (reasoning) => {
reasoning.text = event.data.text
reasoning.time = { created: reasoning.time?.created ?? event.created, completed: event.created }
if (event.data.state !== undefined) reasoning.state = event.data.state
})
return
case "session.retry.scheduled":
message.update(event.data.sessionID, (draft, index) => {
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
if (!currentAssistant) return
currentAssistant.retry = {
attempt: event.data.attempt,
at: event.data.at,
error: event.data.error,
}
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
assistant.retry = { attempt: event.data.attempt, at: event.data.at, error: event.data.error }
})
return
case "session.execution.started":
@@ -1031,16 +969,14 @@ export function createData(config: CreateDataInput) {
return
case "session.compaction.started":
if (event.data.inputID) removePending(event.data.sessionID, event.data.inputID)
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: event.data.inputID ?? messageIDFromEvent(event.id),
type: "compaction",
status: "running",
reason: event.data.reason,
summary: "",
recent: event.data.recent ?? "",
time: { created: event.created },
})
message.insert(event.data.sessionID, {
id: event.data.inputID ?? messageIDFromEvent(event.id),
type: "compaction",
status: "running",
reason: event.data.reason,
summary: "",
recent: event.data.recent ?? "",
time: { created: event.created },
})
if (event.data.inputID) compacting.get(event.data.sessionID)?.observed.add(event.data.inputID)
return
@@ -1075,11 +1011,12 @@ export function createData(config: CreateDataInput) {
if (store.session.info[event.data.sessionID]) {
setStore("session", "info", event.data.sessionID, "revert", undefined)
}
// The projector also deletes inbox items enqueued at or after the boundary without a cancel event.
setStore(
"session",
"input",
"pending",
event.data.sessionID,
(store.session.input[event.data.sessionID] ?? []).filter((id) => id < event.data.to),
(store.session.pending[event.data.sessionID] ?? []).filter((item) => item.id < event.data.to),
)
message.update(event.data.sessionID, (draft, index) => {
const position = draft.findIndex((item) => item.id >= event.data.to)
@@ -1243,8 +1180,8 @@ export function createData(config: CreateDataInput) {
}))
break
case "reference.updated":
result.location.reference.invalidate()
void result.location.reference.sync()
result.location.reference.invalidate(location)
void result.location.reference.sync(location)
break
case "integration.updated":
result.location.integration.invalidate(location)
@@ -1273,6 +1210,41 @@ export function createData(config: CreateDataInput) {
}
}
// A cached per-location catalog. `sync` loads once per invalidation, keyed by the
// effective location, and publishes under the server's canonical location; `alias`
// also publishes under the requested key when the two differ.
function locationResource<Field extends keyof LocationData>(
field: Field,
load: (location: ReturnType<typeof locationQuery>) => Promise<{ location: LocationRef; data: LocationData[Field] }>,
options?: { alias?: boolean },
) {
const publish = (key: string, value: LocationData[Field]) => setStore("location", key, { [field]: value })
return {
list: (ref?: LocationRef) => store.location[locationKey(ref ?? defaultLocation())]?.[field],
sync: (ref?: LocationRef) => {
const location = ref ?? defaultLocation()
const id = locationKey(location)
return sync.run(`location.${field}:${id}`, async () => {
const response = await load(locationQuery(location))
const key = locationKey(response.location)
publish(key, response.data)
if (options?.alias && key !== id) publish(id, response.data)
})
},
invalidate: (ref?: LocationRef) => sync.invalidate(`location.${field}:${locationKey(ref ?? defaultLocation())}`),
}
}
const vcs = locationResource("vcs", (location) => api().vcs.get({ location }))
const shells = locationResource("shell", async (location) => {
const response = await api().shell.list({ location })
const ref = { directory: response.location.directory, workspaceID: response.location.workspaceID }
return {
location: response.location,
data: Object.fromEntries(response.data.map((info) => [info.id, { ...info, location: ref }])),
}
})
const result = {
on: config.event.on,
listen: config.event.listen,
@@ -1321,12 +1293,17 @@ export function createData(config: CreateDataInput) {
status(sessionID: string) {
return store.session.active[sessionID] ?? "idle"
},
// Inputs are the pending user and synthetic items; compactions are control items.
input: {
list(sessionID: string) {
return store.session.input[sessionID] ?? []
return (store.session.pending[sessionID] ?? []).flatMap((item) =>
item.type === "compaction" ? [] : [item.id],
)
},
has(sessionID: string, inboxID: string) {
return store.session.input[sessionID]?.includes(inboxID) ?? false
return (
store.session.pending[sessionID]?.some((item) => item.id === inboxID && item.type !== "compaction") ?? false
)
},
},
pending: {
@@ -1350,12 +1327,6 @@ export function createData(config: CreateDataInput) {
const merged = inflight.length === 0 ? pending : [...pending, ...inflight]
batch(() => {
setStore("session", "pending", sessionID, reconcile(merged))
setStore(
"session",
"input",
sessionID,
reconcile(merged.filter((item) => item.type !== "compaction").map((item) => item.id)),
)
merged.forEach(materializeInboxMessage)
})
})
@@ -1740,7 +1711,7 @@ export function createData(config: CreateDataInput) {
},
shell: {
list(location?: LocationRef) {
return Object.values(store.location[locationKey(location ?? defaultLocation())]?.shell ?? {})
return Object.values(shells.list(location) ?? {})
},
listBySession(sessionID: string) {
return Object.values(store.location)
@@ -1752,31 +1723,8 @@ export function createData(config: CreateDataInput) {
.map((data) => data.shell?.[id])
.find((shell) => shell !== undefined)
},
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.shell:${id}`, async () => {
const response = await api().shell.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(response.location)
setStore("location", key, {
...store.location[key],
shell: Object.fromEntries(
response.data.map((info) => [
info.id,
{
...info,
location: {
directory: response.location.directory,
workspaceID: response.location.workspaceID,
},
},
]),
),
})
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.shell:${locationKey(ref ?? defaultLocation())}`)
},
sync: shells.sync,
invalidate: shells.invalidate,
},
location: {
info(ref?: LocationRef) {
@@ -1831,162 +1779,20 @@ export function createData(config: CreateDataInput) {
result.shell.invalidate(location)
result.session.form.invalidate("global", location)
},
vcs: {
info(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.vcs
},
sync(ref?: LocationRef) {
const location = ref ?? defaultLocation()
return sync.run(`location.vcs:${locationKey(location)}`, async () => {
const response = await api().vcs.get({ location: locationQuery(location) })
const key = locationKey(response.location)
setStore("location", key, { ...store.location[key], vcs: response.data })
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.vcs:${locationKey(ref ?? defaultLocation())}`)
},
},
agent: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.agent
},
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.agent:${id}`, async () => {
const response = await api().agent.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(response.location)
setStore("location", key, { ...store.location[key], agent: response.data })
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.agent:${locationKey(ref ?? defaultLocation())}`)
},
},
command: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.command
},
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.command:${id}`, async () => {
const response = await api().command.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(response.location)
setStore("location", key, { ...store.location[key], command: response.data })
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.command:${locationKey(ref ?? defaultLocation())}`)
},
},
integration: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.integration
},
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.integration:${id}`, async () => {
const response = await api().integration.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(response.location)
setStore("location", key, { ...store.location[key], integration: response.data })
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.integration:${locationKey(ref ?? defaultLocation())}`)
},
},
vcs: { info: vcs.list, sync: vcs.sync, invalidate: vcs.invalidate },
agent: locationResource("agent", (location) => api().agent.list({ location })),
command: locationResource("command", (location) => api().command.list({ location })),
integration: locationResource("integration", (location) => api().integration.list({ location })),
mcp: {
server: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.mcp?.server
},
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.mcp.server:${id}`, async () => {
const response = await api().mcp.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(response.location)
setStore("location", key, {
...store.location[key],
mcp: { ...store.location[key]?.mcp, server: response.data },
})
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.mcp.server:${locationKey(ref ?? defaultLocation())}`)
},
},
resource: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.mcp?.resource
},
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.mcp.resource:${id}`, async () => {
const response = await api().mcp.resource.catalog({
location: locationQuery(ref ?? defaultLocation()),
})
const key = locationKey(response.location)
setStore("location", key, {
...store.location[key],
mcp: { ...store.location[key]?.mcp, resource: response.data.resources },
})
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.mcp.resource:${locationKey(ref ?? defaultLocation())}`)
},
},
},
model: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.model
},
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.model:${id}`, async () => {
const response = await api().model.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(response.location)
setStore("location", key, { ...store.location[key], model: response.data })
if (key !== id) setStore("location", id, { ...store.location[id], model: response.data })
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.model:${locationKey(ref ?? defaultLocation())}`)
},
},
provider: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.provider
},
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.provider:${id}`, async () => {
const response = await api().provider.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(response.location)
setStore("location", key, { ...store.location[key], provider: response.data })
if (key !== id) setStore("location", id, { ...store.location[id], provider: response.data })
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.provider:${locationKey(ref ?? defaultLocation())}`)
},
},
reference: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.reference
},
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.reference:${id}`, async () => {
const response = await api().reference.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(response.location)
setStore("location", key, { ...store.location[key], reference: response.data })
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.reference:${locationKey(ref ?? defaultLocation())}`)
},
server: locationResource("mcpServer", (location) => api().mcp.list({ location })),
resource: locationResource("mcpResource", async (location) => {
const response = await api().mcp.resource.catalog({ location })
return { location: response.location, data: response.data.resources }
}),
},
model: locationResource("model", (location) => api().model.list({ location }), { alias: true }),
provider: locationResource("provider", (location) => api().provider.list({ location }), { alias: true }),
reference: locationResource("reference", (location) => api().reference.list({ location })),
websearch: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.websearch
@@ -2001,22 +1807,7 @@ export function createData(config: CreateDataInput) {
})
},
},
skill: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.skill
},
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.skill:${id}`, async () => {
const response = await api().skill.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(response.location)
setStore("location", key, { ...store.location[key], skill: response.data })
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.skill:${locationKey(ref ?? defaultLocation())}`)
},
},
skill: locationResource("skill", (location) => api().skill.list({ location })),
},
}
+17
View File
@@ -15,6 +15,23 @@ import {
const synced = { type: "log.synced" as const, aggregateID: "ses_test", seq: Event.Seq.make(1) }
test("skill.list decodes a declared plugin callback failure", async () => {
const failure = {
_tag: "PluginCallbackError",
pluginID: "broken-skills",
operation: "skill.transform",
message: 'Plugin "broken-skills" failed during skill.transform. Check server logs for details.',
}
const httpClient = HttpClient.make((request) =>
Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(failure, { status: 500 }))),
)
const error = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* client.skill.list().pipe(Effect.flip)
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(error).toMatchObject(failure)
})
test("health.get decodes the readiness response", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ healthy: true, version: "old", pid: 123 }))),
+16 -1
View File
@@ -1,5 +1,20 @@
import { expect, test } from "bun:test"
import { isSessionNotFoundError, isUnauthorizedError, OpenCode } from "../src/promise/index"
import { isPluginCallbackError, isSessionNotFoundError, isUnauthorizedError, OpenCode } from "../src/promise/index"
test("skill.list preserves a declared plugin callback failure", async () => {
const failure = {
_tag: "PluginCallbackError",
pluginID: "broken-skills",
operation: "skill.transform",
message: 'Plugin "broken-skills" failed during skill.transform. Check server logs for details.',
}
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async () => Response.json(failure, { status: 500 }),
})
await expect(client.skill.list()).rejects.toEqual(failure)
expect(isPluginCallbackError(failure)).toBe(true)
})
test("exposes every standard HTTP API group", () => {
const client = OpenCode.make({ baseUrl: "http://localhost:3000" })
+60
View File
@@ -409,6 +409,66 @@ test("refreshes global credential events across every loaded location and worksp
}
})
test("refreshes references for the location an update names", async () => {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const requests: URL[] = []
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
const url = new URL(request.url)
requests.push(url)
const directory = url.searchParams.get("location[directory]") ?? "/project"
return Response.json({
location: {
directory,
workspaceID: url.searchParams.get("location[workspace]") ?? undefined,
project: { id: "project", directory, canonical: directory },
},
data: [],
})
},
})
const setup = createRoot((dispose) => ({
data: createData({
api: () => api,
directory: "/project",
event: {
on: () => () => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
},
connection: { status: () => "connected" },
}),
dispose,
}))
const other = { directory: "/other", workspaceID: "workspace-other" }
try {
await Promise.all([setup.data.location.reference.sync(), setup.data.location.reference.sync(other)])
requests.length = 0
const updated: OpenCodeEvent = {
id: "evt_reference.updated",
created: 1,
type: "reference.updated",
location: other,
data: {},
}
listeners.forEach((listener) => listener({ name: updated.type, details: updated }))
await wait(() => requests.length === 1)
expect([
requests[0]!.pathname,
requests[0]!.searchParams.get("location[directory]"),
requests[0]!.searchParams.get("location[workspace]"),
]).toEqual(["/api/reference", "/other", "workspace-other"])
} finally {
setup.dispose()
}
})
test("reports optimistic sessions as creating until the request settles", async () => {
const release = Promise.withResolvers<void>()
const api = OpenCode.make({
@@ -20378,44 +20378,6 @@
"required": ["id", "created", "type", "data"],
"additionalProperties": false
},
"plugin.added": {
"type": "object",
"properties": {
"id": {
"type": "string",
"allOf": [
{
"pattern": "^evt_"
}
]
},
"created": {
"type": "number"
},
"metadata": {
"type": "object"
},
"type": {
"type": "string",
"enum": ["plugin.added"]
},
"location": {
"$ref": "#/components/schemas/Location.Ref"
},
"data": {
"type": "object",
"properties": {
"id": {
"type": "string"
}
},
"required": ["id"],
"additionalProperties": false
}
},
"required": ["id", "created", "type", "data"],
"additionalProperties": false
},
"plugin.updated": {
"type": "object",
"properties": {
@@ -23123,9 +23085,6 @@
{
"$ref": "#/components/schemas/permission.v2.replied"
},
{
"$ref": "#/components/schemas/plugin.added"
},
{
"$ref": "#/components/schemas/plugin.updated"
},
+1
View File
@@ -20,6 +20,7 @@
"migration": "bun run script/migration.ts",
"fix-node-pty": "bun run script/fix-node-pty.ts",
"benchmark:location": "bun run script/benchmark-location.ts",
"benchmark:location-memory": "bun run script/benchmark-location-memory.ts",
"build": "bun run script/build.ts",
"update-models-snapshot": "bun run script/update-models-snapshot.ts",
"test": "bun run script/test.ts",
@@ -0,0 +1,210 @@
// Measures the heap retained by Location service graphs and by the models.dev
// catalog plugin. Everything runs against a temporary global directory with a
// temporary home, an in-memory database, no filesystem watchers, and no network,
// so it never touches a live server, database, or user configuration.
//
// bun run script/benchmark-location-memory.ts [--locations 6] [--plugins 8] [--json out.json]
//
// "retained" numbers are heapUsed after two forced GCs; "peak" numbers are the
// highest heapUsed sampled without forcing GC and are reported separately.
import fs from "fs/promises"
import os from "os"
import path from "path"
import { heapStats } from "bun:jsc"
import { Effect, Layer, Logger, Scope } from "effect"
import { Global } from "@opencode-ai/util/global"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { AppNodeBuilder } from "../src/effect/app-node-builder"
import { Bus } from "../src/bus"
import { Catalog } from "../src/catalog"
import { Database } from "../src/database/database"
import { Integration } from "../src/integration"
import { Location } from "../src/location"
import { LocationServiceMap } from "../src/location-service-map"
import { ModelsDev } from "../src/models-dev"
import { Plugin } from "../src/plugin"
import { ModelsDevPlugin } from "../src/plugin/models-dev"
import { AbsolutePath } from "../src/schema"
import { Watcher } from "../src/filesystem/watcher"
import { location } from "../test/fixture/location"
import { catalogHost, host, integrationHost } from "../test/plugin/host"
const args = process.argv.slice(2)
const flag = (name: string, fallback: number) => {
const index = args.indexOf(`--${name}`)
if (index === -1) return fallback
const value = Number(args[index + 1])
if (!Number.isInteger(value) || value < 1) {
console.error(`--${name} must be a positive integer`)
process.exit(1)
}
return value
}
const locationCount = flag("locations", 6)
const pluginCount = flag("plugins", 8)
const jsonIndex = args.indexOf("--json")
const jsonPath = jsonIndex === -1 ? undefined : args[jsonIndex + 1]
type Sample = { heapUsed: number; rss: number; objects: number }
const sample = (): Sample => {
Bun.gc(true)
Bun.gc(true)
const usage = process.memoryUsage()
return { heapUsed: usage.heapUsed, rss: usage.rss, objects: heapStats().objectCount }
}
const mib = (bytes: number) => (bytes / 1024 / 1024).toFixed(2).padStart(8)
const median = (values: ReadonlyArray<number>) => {
const sorted = values.toSorted((a, b) => a - b)
const middle = Math.floor(sorted.length / 2)
const upper = sorted[middle] ?? 0
return sorted.length % 2 === 0 ? ((sorted[middle - 1] ?? 0) + upper) / 2 : upper
}
const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "opencode-location-memory-")))
const globalLayer = Global.layerWith({
home: path.join(root, "home"),
data: path.join(root, "data"),
cache: path.join(root, "cache"),
config: path.join(root, "config"),
state: path.join(root, "state"),
tmp: path.join(root, "tmp"),
bin: path.join(root, "cache", "bin"),
log: path.join(root, "data", "log"),
repos: path.join(root, "data", "repos"),
})
const replacements = [
Global.node.replace(globalLayer),
ModelsDev.node.replace(ModelsDev.configured({ fetch: false })),
Watcher.node.replace(Watcher.configured({ enabled: false })),
]
// One full Location graph per directory, retained for the rest of the run, the
// way a long-running server retains every directory a client has touched.
const locationsProgram = Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const scope = yield* Scope.Scope
const before = sample()
const deltas: number[] = []
const rss: number[] = []
let previous = before
for (let index = 0; index < locationCount; index++) {
const directory = path.join(root, "projects", `location-${index}`)
yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
const context = yield* locations
.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(directory) }))
.pipe(Scope.provide(scope))
const plugins = yield* Plugin.Service.pipe(Effect.provideContext(context))
yield* plugins.awaitActivation
const current = sample()
deltas.push(current.heapUsed - previous.heapUsed)
rss.push(current.rss)
previous = current
}
const catalog = yield* Catalog.Service.pipe(
Effect.provideContext(
yield* locations
.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(path.join(root, "projects", `location-0`)) }))
.pipe(Scope.provide(scope)),
),
)
const models = yield* catalog.model.all()
const providers = yield* catalog.provider.all()
return {
before,
after: previous,
deltas,
rss,
catalog: { providers: providers.length, models: models.length },
}
}).pipe(Effect.scoped)
// The models.dev plugin alone, against a real Catalog and Integration state per
// instance, isolates the catalog-copy contribution from the rest of the graph.
const pluginProgram = Effect.gen(function* () {
const modelsDev = yield* ModelsDev.Service
const snapshot = yield* modelsDev.get()
const scope = yield* Scope.Scope
const before = sample()
const deltas: number[] = []
let previous = before
for (let index = 0; index < pluginCount; index++) {
const directory = AbsolutePath.make(path.join(root, "plugins", `instance-${index}`))
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location(Location.Ref.make({ directory }))),
)
const context = yield* Layer.build(
AppNodeBuilder.build(LayerNode.group([Catalog.node, Integration.node, Bus.node]), [
Location.node.replace(locationLayer),
...replacements,
]),
).pipe(Scope.provide(scope))
const catalog = yield* Catalog.Service.pipe(Effect.provideContext(context))
const integration = yield* Integration.Service.pipe(Effect.provideContext(context))
yield* ModelsDevPlugin.effect(
host({ catalog: catalogHost(catalog), integration: integrationHost(integration) }),
).pipe(Effect.provideService(ModelsDev.Service, modelsDev), Effect.provideContext(context), Scope.provide(scope))
yield* catalog.model.all()
yield* integration.list()
const current = sample()
deltas.push(current.heapUsed - previous.heapUsed)
previous = current
}
return {
before,
after: previous,
deltas,
snapshot: {
providers: snapshot.length,
models: snapshot.reduce((total, provider) => total + provider.models.length, 0),
},
}
}).pipe(Effect.scoped)
const program = Effect.gen(function* () {
const plugin = yield* pluginProgram.pipe(
Effect.provide(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, ModelsDev.node]), replacements)),
)
const locations = yield* locationsProgram.pipe(
Effect.provide(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node]), replacements),
),
)
return { plugin, locations }
}).pipe(Effect.provide(Logger.layer([])))
const result = await Effect.runPromise(program)
await fs.rm(root, { recursive: true, force: true }).catch(() => undefined)
console.log(
`models.dev snapshot: ${result.plugin.snapshot.providers} providers, ${result.plugin.snapshot.models} models`,
)
console.log(`ModelsDevPlugin instances: ${pluginCount}`)
console.log(` retained heap per instance (MiB): ${result.plugin.deltas.map((delta) => mib(delta).trim()).join(", ")}`)
console.log(` median per instance: ${mib(median(result.plugin.deltas))} MiB`)
console.log(
`Location graphs: ${locationCount} (catalog ${result.locations.catalog.providers} providers, ${result.locations.catalog.models} models each)`,
)
console.log(
` retained heap per location (MiB): ${result.locations.deltas.map((delta) => mib(delta).trim()).join(", ")}`,
)
console.log(` median per location: ${mib(median(result.locations.deltas))} MiB`)
console.log(
` heapUsed before ${mib(result.locations.before.heapUsed)} MiB -> after ${mib(result.locations.after.heapUsed)} MiB`,
)
console.log(` rss before ${mib(result.locations.before.rss)} MiB -> after ${mib(result.locations.after.rss)} MiB`)
if (jsonPath) {
await fs.mkdir(path.dirname(jsonPath), { recursive: true })
await fs.writeFile(
jsonPath,
JSON.stringify(
{ revision: process.env.OPENCODE_BENCH_REVISION, bun: Bun.version, locationCount, pluginCount, ...result },
null,
2,
),
)
}
+1
View File
@@ -100,6 +100,7 @@ const layer = Layer.effect(
editor.providers.set(providerID, current)
}
fn(current.provider)
current.provider.id = providerID
},
remove: (providerID) => {
editor.providers.delete(providerID)
+39 -19
View File
@@ -3,10 +3,11 @@ export * as ConfigPluginSource from "./source.js"
import { Directory, Document, type Entry } from "@opencode-ai/schema/config"
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Host } from "@opencode-ai/plugin/host"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Option, PubSub, Scope, Stream } from "effect"
import path from "path"
import { fileURLToPath } from "url"
import { fileURLToPath, pathToFileURL } from "url"
import { Config } from "../../config.js"
import { Watcher } from "../../filesystem/watcher.js"
import { Location } from "../../location.js"
@@ -41,8 +42,8 @@ export const layer = Layer.effect(
const configuredChanges = yield* PubSub.unbounded<void>()
const watched = new Set<string>()
// Configured local plugin entrypoints can live outside config roots, where the
// config change feed cannot see them; watch those entrypoints directly.
// Configured local plugins can live outside config roots, where the
// config change feed cannot see them; watch those targets directly.
// Watches start on first sighting and are never torn down individually:
// a stale watch after a config edit costs one deduped fs handle and a
// no-op activation, and every watch dies with this layer's scope.
@@ -56,7 +57,10 @@ export const layer = Layer.effect(
// The config change feed already covers {plugin,plugins} directories.
if (isPluginSource(entries, operation.target)) continue
watched.add(operation.target)
const updates = yield* watcher.subscribe({ path: operation.target, type: "file" })
const updates = yield* watcher.subscribe({
path: operation.target,
type: (yield* fs.isDir(operation.target)) ? "directory" : "file",
})
yield* updates.pipe(
Stream.runForEach(() => PubSub.publish(configuredChanges, undefined)),
Effect.catchCause((cause) =>
@@ -148,24 +152,40 @@ const scan = Effect.fn("ConfigPluginSource.scan")(function* (
yield* Effect.logWarning("configured plugin path must be a directory", { target: operation.target })
return Option.none<Operation>()
}
if (!(yield* fs.isDir(operation.target))) return Option.some<Operation>(operation)
const entrypoint = yield* PluginSourceDirectory.entrypoint(fs, operation.target)
if (Option.isSome(entrypoint)) return Option.some<Operation>({ ...operation, target: entrypoint.value })
yield* Effect.logWarning("configured plugin directory has no index entrypoint", { target: operation.target })
return Option.none<Operation>()
return Option.some<Operation>(operation)
}),
).pipe(Effect.map((operations) => operations.flatMap(Option.toArray)))
// Explicit config is applied last so it can remove auto-discovered packages.
return yield* Effect.forEach([...discovered, ...resolved], (operation) => {
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return Effect.succeed(operation)
return fs.stat(operation.target).pipe(
Effect.map((info) => ({
...operation,
mtime: Option.getOrElse(info.mtime, () => new Date(0)).getTime(),
})),
Effect.orElseSucceed(() => operation),
)
})
return yield* Effect.forEach([...discovered, ...resolved], (operation) =>
Effect.gen(function* () {
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return [operation]
if (!(yield* fs.existsSafe(operation.target))) return [operation]
const directory = yield* fs.isDir(operation.target)
const entrypoints: Host.Entrypoints = directory
? yield* Effect.sync(() => Host.resolve({ directory: operation.target }))
: { server: pathToFileURL(operation.target).href }
if (!entrypoints.server) return []
if (directory) {
const root = yield* fs.resolve(operation.target)
const server = yield* fs.resolve(fileURLToPath(entrypoints.server))
if (!FSUtil.contains(root, server)) return []
}
const times = yield* Effect.forEach(
[
...Object.values(entrypoints)
.filter((entry) => entry !== undefined)
.map((entry) => fileURLToPath(entry)),
path.join(directory ? operation.target : path.dirname(operation.target), "package.json"),
],
(entry) =>
fs.stat(entry).pipe(
Effect.map((info) => Option.getOrElse(info.mtime, () => new Date(0)).getTime()),
Effect.orElseSucceed(() => 0),
),
)
return [{ ...operation, mtime: Math.max(...times) }]
}),
).pipe(Effect.map((operations) => operations.flat()))
})
function isPluginSource(entries: readonly Entry[], file: string) {
+32 -25
View File
@@ -20,7 +20,9 @@ const layer = Layer.effect(
const bus = yield* Bus.Service
const kv = yield* KV.Service
const scope = yield* Scope.make()
const active = new Map<Plugin.ID, { readonly plugin: Generation; readonly scope: Scope.Closeable }>()
// One slot per requested definition in activation order, including ones whose setup failed, so
// the prefix diff below stays index-aligned and a failed revision is not retried until it changes.
const active = new Map<Plugin.ID, Slot>()
const lock = Semaphore.makeUnsafe(1)
const ready = yield* Latch.make(true)
const pending = new Set<object>()
@@ -41,9 +43,7 @@ const layer = Layer.effect(
const load = Effect.fnUntraced(function* (plugin: Generation) {
const child = yield* Scope.fork(scope)
const inherit = yield* State.inherit()
const loaded = yield* Effect.suspend(() =>
plugin.effect({ ...host, storage: PluginHost.storage(kv, plugin.id) }),
).pipe(
const loaded = yield* Effect.suspend(() => plugin.effect(PluginHost.forPlugin(host, kv, plugin.id))).pipe(
inherit,
Effect.updateContext((context: Context.Context<never>) =>
Context.make(Scope.Scope, child).pipe(
@@ -52,7 +52,6 @@ const layer = Layer.effect(
),
),
Effect.withSpan("Plugin.load", { attributes: { "plugin.id": plugin.id } }),
Effect.andThen(bus.publish(Plugin.Event.Added, { id: Plugin.ID.make(plugin.id) })),
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
Effect.exit,
)
@@ -91,7 +90,7 @@ const layer = Layer.effect(
if (entry) active.set(definition.id, { ...entry, plugin: definition })
}
if (prefix === definitions.length && active.size === definitions.length) {
const nextInventory = [...definitions.map(activeInfo), ...failures]
const nextInventory = [...Array.from(active.values()).map(slotInfo), ...failures]
if (JSON.stringify(inventory) === JSON.stringify(nextInventory)) return
inventory = nextInventory
yield* bus.publish(Plugin.Event.Updated, {})
@@ -104,33 +103,33 @@ const layer = Layer.effect(
const previous = new Map(Array.from(active.entries()).slice(prefix))
yield* Effect.forEach(
Array.from(previous.entries()).toReversed(),
([id, entry]) =>
([id, slot]) =>
Effect.gen(function* () {
active.delete(id)
yield* Scope.close(entry.scope, Exit.void)
if (slot.loaded) yield* Scope.close(slot.loaded.scope, Exit.void)
}),
{ discard: true },
)
const nextInventory = definitions.slice(0, prefix).map(activeInfo)
for (const definition of definitions.slice(prefix)) {
const loaded = yield* load(definition)
if (loaded.scope !== undefined) {
active.set(definition.id, { plugin: definition, scope: loaded.scope })
nextInventory.push(activeInfo(definition))
active.set(definition.id, {
plugin: definition,
loaded: { plugin: definition, scope: loaded.scope },
})
continue
}
nextInventory.push({
id: definition.id,
source: definition.source ?? { type: "builtin" },
state: { status: "failed", error: loaded.error },
features: { server: true, ...definition.features },
})
active.set(definition.id, { plugin: definition, error: loaded.error })
const fallback = previous.get(definition.id)
const fallback = previous.get(definition.id)?.loaded
if (!fallback) continue
const restored = yield* load(fallback.plugin)
if (restored.scope !== undefined) {
active.set(definition.id, { plugin: fallback.plugin, scope: restored.scope })
active.set(definition.id, {
plugin: definition,
loaded: { plugin: fallback.plugin, scope: restored.scope },
error: loaded.error,
})
continue
}
yield* Effect.logError("failed to restore plugin; deactivating", {
@@ -138,7 +137,7 @@ const layer = Layer.effect(
})
}
inventory = [...nextInventory, ...failures]
inventory = [...Array.from(active.values()).map(slotInfo), ...failures]
}),
)
yield* bus.publish(Plugin.Event.Updated, {})
@@ -167,12 +166,20 @@ const layer = Layer.effect(
}),
)
function activeInfo(plugin: Generation): Plugin.Info {
// `plugin` is the definition the slot was last asked to run; `loaded` is the generation actually
// running, which stays an older fallback while the requested revision keeps failing setup.
type Slot = {
readonly plugin: Generation
readonly loaded?: { readonly plugin: Generation; readonly scope: Scope.Closeable }
readonly error?: string
}
function slotInfo(slot: Slot): Plugin.Info {
return {
id: Plugin.ID.make(plugin.id),
source: plugin.source ?? { type: "builtin" },
state: { status: "active" },
features: { server: true, ...plugin.features },
id: Plugin.ID.make(slot.plugin.id),
source: slot.plugin.source ?? { type: "builtin" },
state: slot.error === undefined ? { status: "active" } : { status: "failed", error: slot.error },
features: { server: true, ...slot.plugin.features },
}
}
+14
View File
@@ -0,0 +1,14 @@
export * as PluginCallback from "./callback.js"
import { Data } from "effect"
/** Local failure detail. Transport boundaries must explicitly select public fields. */
export class Error extends Data.TaggedError("PluginCallbackError")<{
readonly pluginID: string
readonly operation: "skill.transform"
readonly cause: unknown
}> {
override get message() {
return `Plugin "${this.pluginID}" failed during ${this.operation}.`
}
}
+22
View File
@@ -33,6 +33,7 @@ import { WebSearch } from "../websearch.js"
import { Generate } from "../generate.js"
import { Permission } from "../permission.js"
import { PluginHooks } from "./hooks.js"
import { PluginCallback } from "./callback.js"
import type { Interface } from "../plugin.js"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
@@ -397,6 +398,7 @@ export const make = Effect.fn("PluginHost.make")(function* (
add: (name, source) => editor.add(name, Schema.decodeUnknownSync(Reference.Source)(source)),
remove: editor.remove,
list: editor.list,
get: editor.get,
})
}),
},
@@ -407,6 +409,7 @@ export const make = Effect.fn("PluginHost.make")(function* (
skill.transform((editor) => {
callback({
list: () => mutable(editor.list()),
get: editor.get,
add: (value) => editor.add(Schema.decodeUnknownSync(Skill.Info)(value)),
update: editor.update,
remove: editor.remove,
@@ -516,6 +519,25 @@ export const requirements = LayerNode.group([
LocationServiceMap.node,
])
export function forPlugin(host: Plugin.Context, kv: KV.Interface, pluginID: string): Plugin.Context {
return {
...host,
storage: storage(kv, pluginID),
skill: {
...host.skill,
transform: (callback) =>
host.skill.transform((editor) => {
try {
callback(editor)
} catch (cause) {
// Replay happens after setup, potentially in a different consumer's Effect context.
throw new PluginCallback.Error({ pluginID, operation: "skill.transform", cause })
}
}),
},
}
}
export function storage(kv: KV.Interface, pluginID: string): Plugin.Context["storage"] {
const namespace = `plugin:${pluginID
.split("")
+8 -5
View File
@@ -10,6 +10,8 @@ export const ModelsDevPlugin = define({
effect: Effect.fn(function* (ctx) {
const modelsDev = yield* ModelsDev.Service
const bus = yield* Bus.Service
// The normalized snapshot is shared by every Location and only read here; the catalog
// receives copies below, so retaining a second copy per Location is unnecessary.
const loaded = { data: snapshots(yield* modelsDev.get()) }
yield* ctx.integration.transform((integrations) => {
for (const provider of loaded.data) {
@@ -32,7 +34,7 @@ export const ModelsDevPlugin = define({
yield* ctx.catalog.transform((catalog) => {
for (const provider of loaded.data) {
catalog.provider.update(provider.info.id, (draft) => {
Object.assign(draft, provider.info)
Object.assign(draft, copy(provider.info))
draft.integrationID = Integration.ID.make(provider.info.id)
})
for (const model of provider.models) {
@@ -65,15 +67,16 @@ function environmentNames(provider: ModelsDev.Snapshot) {
}
function snapshots(data: readonly ModelsDev.Snapshot[]) {
return copy(data).filter(
return data.filter(
// These deprecated aliases are replaced by the canonical Azure and Google Vertex providers.
(provider) => provider.info.id !== "azure-cognitive-services" && provider.info.id !== "google-vertex-anthropic",
)
}
// The catalog owns and mutates its model records, so every rebuild needs fresh copies of the
// thousands of snapshot models. Snapshot data is plain JSON, and a direct copy is an order of
// magnitude faster than structuredClone's general graph walk on the startup path.
// The catalog owns and mutates its provider and model records in place, so every rebuild
// needs fresh copies of the thousands of shared snapshot records. Snapshot data is plain
// JSON, and a direct copy is an order of magnitude faster than structuredClone's general
// graph walk on the startup path.
function copy<T>(value: T): T {
if (Array.isArray(value)) return value.map(copy) as T
if (value !== null && typeof value === "object") {
+24 -43
View File
@@ -1,12 +1,12 @@
export * as PluginModule from "./module.js"
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
import { Host } from "@opencode-ai/plugin/host"
import { Npm } from "@opencode-ai/util/npm"
import { importModule } from "@opencode-ai/util/runtime-import"
import { Effect, Schema } from "effect"
import { readdir } from "node:fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { stat } from "node:fs/promises"
import { fileURLToPath, pathToFileURL } from "url"
import type { ConfigPluginSource } from "../config/plugin/source.js"
import type { Generation } from "../plugin.js"
import { PluginPromise } from "./promise.js"
@@ -35,21 +35,27 @@ export const load = Effect.fn("PluginModule.load")(function* (
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
options?: { readonly install?: boolean },
) {
const npm = yield* Npm.Service
const local = path.isAbsolute(operation.target)
const installed: Npm.EntryPoint = local
? { directory: path.dirname(operation.target), entrypoint: pathToFileURL(operation.target).href }
const npm = yield* Npm.Service
const installed = local
? undefined
: options?.install === false
? yield* npm.resolve(operation.target, { subpaths: ["server", ""] })
: yield* npm.add(operation.target, { subpaths: ["server", ""] })
const entrypoint = installed.entrypoint
? yield* npm.resolve(operation.target)
: yield* npm.add(operation.target)
// Legacy auto-discovery still admits standalone server sources. Configured
// local plugins always arrive here as directories.
const entrypoints: Host.Entrypoints =
local && (yield* Effect.promise(() => stat(operation.target))).isFile()
? { server: pathToFileURL(operation.target).href }
: yield* Effect.sync(() => Host.resolve(installed ?? { directory: operation.target }))
const entrypoint = entrypoints.server
if (!local && options?.install === false && !entrypoint) return { pending: true as const }
if (!entrypoint) return yield* new LoadError({ message: `Plugin entrypoint not found: ${operation.target}` })
// Bun currently ignores query parameters when caching file:// imports.
const target = typeof Bun !== "undefined" ? operation.target.replaceAll("\\", "/") : entrypoint
const target = typeof Bun !== "undefined" ? fileURLToPath(entrypoint).replaceAll("\\", "/") : entrypoint
const source = operation.mtime === undefined ? entrypoint : `${target}?mtime=${operation.mtime}`
yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint: source })
const mod = yield* Effect.promise(() => importModule(source))
const mod = yield* Effect.promise(() => Host.load(source))
const value = (yield* Schema.decodeUnknownEffect(Module)(mod).pipe(
Effect.mapError(
(cause) =>
@@ -60,45 +66,20 @@ export const load = Effect.fn("PluginModule.load")(function* (
),
)).default
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
const features = local
? yield* localFeatures(operation.target)
: yield* Effect.all({
tui: npm.resolve(operation.target, { subpaths: ["tui"] }),
rpc: npm.resolve(operation.target, { subpaths: ["rpc"] }),
}).pipe(
Effect.map((resolved) => ({
...(resolved.tui.entrypoint ? { tui: true as const } : {}),
...(resolved.rpc.entrypoint ? { rpc: true as const } : {}),
})),
)
return {
id: plugin.id,
features,
revision: JSON.stringify([operation, installed.revision]),
features: {
...(entrypoints.tui ? { tui: true as const } : {}),
...(entrypoints.rpc ? { rpc: true as const } : {}),
},
revision: JSON.stringify([operation, installed?.revision]),
source: path.isAbsolute(operation.target)
? { type: "local" as const, path: operation.target }
? { type: "local" as const, path: fileURLToPath(entrypoint) }
: {
type: "package" as const,
target: operation.target,
...(installed.version ? { version: installed.version } : {}),
...(installed?.version ? { version: installed.version } : {}),
},
effect: (host) => plugin.effect({ ...host, options: operation.options }),
} satisfies Generation
})
function localFeatures(entrypoint: string) {
if (!path.basename(entrypoint).startsWith("index.")) return Effect.succeed({})
return Effect.promise(() => readdir(path.dirname(entrypoint), { withFileTypes: true })).pipe(
Effect.map((entries) => {
const names = new Set(
entries.filter((entry) => entry.isFile() || entry.isSymbolicLink()).map((entry) => entry.name),
)
const has = (name: string) =>
["ts", "tsx", "js", "jsx", "mts", "mjs", "cts", "cjs"].some((extension) => names.has(`${name}.${extension}`))
return {
...(has("tui") ? { tui: true as const } : {}),
...(has("rpc") ? { rpc: true as const } : {}),
}
}),
)
}
+1 -1
View File
@@ -12,7 +12,7 @@ import { Permission } from "../permission.js"
const plan = Agent.ID.make("plan")
const enter = (directory: string) => `<system-reminder>
You are in Plan mode. You may optionally create or update plan documents in:
You are in Plan mode. Discuss the plan with the user directly in the conversation. Do not create or update plan files unless the user explicitly asks you to; when they do, write them only in:
${directory}
Do not modify any other files or ask a subagent to do so.
@@ -1,17 +1,16 @@
import { Effect } from "effect"
import { pathToFileURL } from "url"
import { Npm } from "@opencode-ai/util/npm"
import { importModule } from "@opencode-ai/util/runtime-import"
import { importModule, resolveModule } from "@opencode-ai/util/runtime-import"
export const loadSDKFactory = Effect.fnUntraced(function* (npm: Npm.Interface, packageName: string) {
const installedPath = packageName.startsWith("file://")
? packageName
: (yield* npm.add(packageName).pipe(Effect.orDie)).entrypoint
if (!installedPath) return yield* Effect.die(new Error(`Package ${packageName} has no import entrypoint`))
: yield* npm.add(packageName).pipe(
Effect.orDie,
Effect.map((installed) => resolveModule(installed.name, installed.directory)),
)
const mod = (yield* Effect.promise(() =>
importModule(installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href),
)) as Record<string, unknown>
const mod = (yield* Effect.promise(() => importModule(installedPath))) as Record<string, unknown>
const match = Object.keys(mod).find((name) => name.startsWith("create"))
if (!match) return yield* Effect.die(new Error(`Package ${packageName} has no provider factory export`))
return mod[match]
+2 -21
View File
@@ -22,31 +22,12 @@ export const discover = Effect.fn("PluginSourceDirectory.discover")(function* (
Effect.gen(function* () {
const source = entry.target.endsWith(".ts") || entry.target.endsWith(".js")
if (entry.type === "file" && source) return Option.some(entry.target)
if (entry.type === "directory") return yield* entrypoint(fs, entry.target)
if (entry.type === "directory") return Option.some(entry.target)
if (entry.type !== "symlink") return Option.none<string>()
if (source && (yield* fs.isFile(entry.target))) return Option.some(entry.target)
if (yield* fs.isDir(entry.target)) return yield* entrypoint(fs, entry.target)
if (yield* fs.isDir(entry.target)) return Option.some(entry.target)
return Option.none<string>()
}),
)
return targets.flatMap(Option.toArray)
})
export function entrypoint(fs: FSUtil.Interface, directory: string) {
return Effect.gen(function* () {
const root = yield* fs.resolve(directory)
return yield* Effect.findFirst(
["index.ts", "index.js"].map((entry) => path.join(directory, entry)),
(entry) =>
fs
.isFile(entry)
.pipe(
Effect.flatMap((exists) =>
exists
? fs.resolve(entry).pipe(Effect.map((resolved) => FSUtil.contains(root, resolved)))
: Effect.succeed(false),
),
),
)
})
}
+36 -33
View File
@@ -1,7 +1,7 @@
export * as PluginSupervisor from "./supervisor.js"
import { Event } from "@opencode-ai/schema/config"
import { Cause, Effect, Layer, Stream } from "effect"
import { Cause, Effect, Layer, Queue, Stream } from "effect"
import path from "path"
import { ConfigPluginSource } from "../config/plugin/source.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
@@ -19,6 +19,7 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
post: readonly Plugin.Generation[],
operations: readonly ConfigPluginSource.Operation[],
install: boolean,
running: ReadonlyMap<string, Plugin.Generation>,
) {
const matches = (selector: string, target: string) =>
selector === "*" || (selector.endsWith(".*") ? target.startsWith(selector.slice(0, -1)) : selector === target)
@@ -71,6 +72,11 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
state: { status: "failed", error: plugin.error, ref: plugin.ref },
features: { server: true },
})
// The new revision never became a generation, so the one already running keeps its place.
const retained = packages.get(operation.target) ?? running.get(operation.target)
if (!retained) continue
packages.set(operation.target, retained)
enabled.add(retained.id)
continue
}
failures.delete(operation.target)
@@ -91,6 +97,7 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
ordered.findIndex((other) => other.id === plugin.id) !== index
return {
plugins: ordered.filter((plugin, index) => !duplicate(plugin, index)),
packages: new Map([...packages].filter(([, plugin]) => enabled.has(plugin.id))),
failures: [
...failures.values(),
...ordered.filter(duplicate).map((plugin) => ({
@@ -118,6 +125,8 @@ export const layer = Layer.effectDiscard(
// Built-ins capture services from this layer; unload them before those services close.
yield* Effect.addFinalizer(registry.close)
let packages = new Set<string>()
// Last generation handed to the registry per target; a failed reload keeps it in place.
let running = new Map<string, Plugin.Generation>()
let outdated = new Set<string>()
const updating = new Set<string>()
let generation = 0
@@ -140,7 +149,7 @@ export const layer = Layer.effectDiscard(
}))
const operations = yield* sources.operations()
// Activate everything available locally before waiting on missing package installs.
const immediate = yield* resolve(pre, post, operations, false)
const immediate = yield* resolve(pre, post, operations, false, running)
const source = (source: Plugin.Source) =>
source.type === "package"
? {
@@ -155,16 +164,17 @@ export const layer = Layer.effectDiscard(
resolved.failures.map((failure) => ({ ...failure, source: source(failure.source) })),
)
yield* apply(immediate)
const resolved = immediate.pending.length ? yield* resolve(pre, post, operations, true) : immediate
const resolved = immediate.pending.length ? yield* resolve(pre, post, operations, true, running) : immediate
if (resolved !== immediate) yield* apply(resolved)
const loaded = new Set(
running = resolved.packages
const targets = new Set(
[...resolved.plugins, ...resolved.failures].flatMap((plugin) =>
plugin.source?.type === "package" ? [plugin.source.target] : [],
),
)
packages = loaded
packages = targets
yield* Effect.forEach(
loaded,
targets,
(target) => updates.check(target).pipe(Effect.map((available) => [target, available] as const)),
{ concurrency: "unbounded" },
).pipe(
@@ -178,8 +188,24 @@ export const layer = Layer.effectDiscard(
Effect.forkScoped({ startImmediately: true }),
)
})
const reloads = Stream.merge(
Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])),
// Start source consumers before activation, without an extra merge/debounce boundary delaying them.
// Each source owns its upstream subscriptions; the queue retains the latest observed request.
const triggers = yield* Queue.sliding<number>(1)
// Make accepted work visible to awaitActivation before coalescing the burst.
const notify = Effect.gen(function* () {
observed++
if (!release) release = yield* registry.hold()
yield* Queue.offer(triggers, observed)
})
const watch = <A>(stream: Stream.Stream<A>) =>
stream.pipe(
Stream.runForEach(() => notify),
Effect.forkScoped({ startImmediately: true }),
)
yield* watch(sources.changes())
yield* watch(Stream.fromEffectRepeat(Effect.sleep("24 hours")))
yield* watch(bus.subscribe([Event.Updated, SdkPlugins.Updated]))
yield* watch(
updates.changes().pipe(
Stream.filter((update) => packages.has(update.target)),
Stream.tap((update) =>
@@ -188,22 +214,10 @@ export const layer = Layer.effectDiscard(
update.updating ? updating.add(update.target) : updating.delete(update.target)
}),
),
Stream.map(() => undefined),
),
).pipe(
// Make accepted work visible to awaitActivation before coalescing the burst.
Stream.mapEffect(() =>
Effect.gen(function* () {
observed++
if (!release) release = yield* registry.hold()
return observed
}),
),
)
yield* Stream.concat(Stream.succeed(0), reloads).pipe(
// Keep observing updates while activation runs, retaining only the latest generation request.
Stream.buffer({ capacity: 1, strategy: "sliding" }),
Stream.debounce("100 millis"),
// Run initial activation immediately; debounce only later requests. One consumer serializes both.
yield* Stream.concat(Stream.succeed(0), Stream.fromQueue(triggers).pipe(Stream.debounce("100 millis"))).pipe(
Stream.runForEach((target) =>
Effect.gen(function* () {
yield* activate().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause })))
@@ -215,17 +229,6 @@ export const layer = Layer.effectDiscard(
),
Effect.forkScoped({ startImmediately: true }),
)
yield* Effect.sleep("24 hours").pipe(
Effect.andThen(
Effect.acquireUseRelease(
registry.hold(),
() => activate(),
(release) => release,
),
),
Effect.forever,
Effect.forkScoped,
)
}),
)
+1 -2
View File
@@ -95,8 +95,7 @@ export const loadPackage = Effect.fn("Provider.loadPackage")(function* (specifie
const root = specifier.startsWith("@") ? parts.slice(0, 2).join("/") : (parts[0] ?? specifier)
const installed = yield* npm.add(root).pipe(Effect.mapError((cause) => new LoadError({ package: specifier, cause })))
const entrypoint = yield* Effect.try({
try: () =>
specifier === root && installed.entrypoint ? installed.entrypoint : resolveModule(specifier, installed.directory),
try: () => resolveModule(specifier, installed.directory),
catch: (cause) => new LoadError({ package: specifier, cause }),
})
return yield* importPackage(specifier, entrypoint)
+2
View File
@@ -32,6 +32,7 @@ type Editor = {
add(name: string, source: Source): void
remove(name: string): void
list(): readonly [string, Source][]
get(name: string): Source | undefined
}
export interface Interface extends State.Transformable<Editor> {
@@ -98,6 +99,7 @@ const layer = Layer.effect(
add: (name, source) => editor.sources.set(name, source),
remove: (name) => editor.sources.delete(name),
list: () => Array.from(editor.sources),
get: (name) => editor.sources.get(name),
}),
notify: () =>
Effect.gen(function* () {
+1 -4
View File
@@ -18,7 +18,6 @@ import { KV } from "./kv.js"
const Refresh = Schema.Struct({
attemptedAt: Schema.Number,
refreshedAt: Schema.optionalKey(Schema.Number),
})
const refreshInterval = Duration.toMillis(Duration.days(1))
@@ -161,7 +160,7 @@ const layer = Layer.effect(
if (status !== "cached") {
// Record attempts before network work so failures obey the same refresh interval.
yield* kv.set(key, { ...previous, attemptedAt: now })
yield* kv.set(key, { attemptedAt: now })
if (status === "cloned") {
yield* git.repo
@@ -205,8 +204,6 @@ const layer = Layer.effect(
.resetHard(existing, target ? `origin/${target}` : "HEAD")
.pipe(Effect.mapError((error) => new ResetFailedError({ repository, message: error.message })))
}
yield* kv.set(key, { attemptedAt: now, refreshedAt: yield* Clock.currentTimeMillis })
}
const checkout = yield* git.repo.discover(AbsolutePath.make(localPath))
+3 -1
View File
@@ -53,7 +53,9 @@ export interface Prepared {
* One request-scoped execution operation. Unknown and hook-removed calls
* fail individually through the same seam.
*/
readonly executeTool: (input: Parameters<Tool.Snapshot["execute"]>[0]) => Effect.Effect<Tool.Result, ExecuteError>
readonly executeTool: (
input: Parameters<Tool.Snapshot["execute"]>[0],
) => Effect.Effect<Tool.NormalizedResult, ExecuteError>
}
interface PrepareInput {
@@ -4,7 +4,7 @@ import type { Model } from "@opencode-ai/schema/model"
import type { RelativePath } from "@opencode-ai/schema/schema"
import type { Snapshot } from "@opencode-ai/schema/snapshot"
import { Effect, Fiber, Iterable } from "effect"
import { isArrayNonEmpty, isReadonlyArrayNonEmpty } from "effect/Array"
import { isReadonlyArrayNonEmpty } from "effect/Array"
import { Bus } from "../../bus.js"
import { SessionEvent } from "../event.js"
import { SessionMessage } from "../message.js"
@@ -12,7 +12,7 @@ import { SessionSchema } from "../schema.js"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Money } from "@opencode-ai/schema/money"
import { SessionUsage } from "../usage.js"
import { Tool } from "@opencode-ai/schema/tool"
import type { Tool } from "../../tool.js"
type Input = {
readonly sessionID: SessionSchema.ID
@@ -557,20 +557,15 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
})
/** Publishes one canonical terminal event for a locally executed tool call. */
const toolExecution = Effect.fnUntraced(function* (id: string, name: string, result: Tool.Result) {
const toolExecution = Effect.fnUntraced(function* (id: string, name: string, result: Tool.NormalizedResult) {
const tool = tools.get(id)
if (!tool?.called) return yield* Effect.die(new Error(`Tool execution before call: ${id}`))
if (tool.name !== name)
return yield* Effect.die(new Error(`Tool execution name changed for ${id}: ${tool.name} -> ${name}`))
if (tool.settled) return yield* Effect.die(new Error(`Duplicate tool execution: ${id}`))
tool.settled = true
const content =
typeof result.content === "string"
? [{ type: "text" as const, text: result.content }]
: result.content === undefined
? []
: [...result.content]
if (!isArrayNonEmpty(content)) return yield* Effect.die(new Error(`Tool execution has no content: ${id}`))
const content = result.content
if (!isReadonlyArrayNonEmpty(content)) return yield* Effect.die(new Error(`Tool execution has no content: ${id}`))
yield* bus.publish(SessionEvent.Tool.Success, {
sessionID: input.sessionID,
assistantMessageID,
+2
View File
@@ -74,6 +74,7 @@ export type Data = {
export type Editor = {
list: () => readonly Types.DeepMutable<Info>[]
get: (id: string) => Types.DeepMutable<Info> | undefined
add: (skill: Info) => void
update: (id: string, update: (skill: Types.DeepMutable<Info>) => void) => void
remove: (id: string) => void
@@ -96,6 +97,7 @@ const layer = Layer.effect(
initial: () => ({ skills: new Map() }),
editor: (editor) => ({
list: () => Array.from(editor.skills.values()),
get: (id) => editor.skills.get(ID.make(id)),
add: (skill) => {
editor.skills.set(skill.id, { ...skill } as Types.DeepMutable<Info>)
},
+3 -4
View File
@@ -1,7 +1,7 @@
export * as ToolOutput from "./tool-output.js"
import path from "path"
import type { Tool } from "@opencode-ai/schema/tool"
import type { Tool } from "./tool.js"
import { Context, Duration, Effect, Layer, Schedule } from "effect"
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
@@ -15,7 +15,7 @@ export const MAX_BYTES = 50 * 1024 // 50 KiB
export const RETENTION = Duration.days(7)
export const DIRECTORY = "tool-output"
type Result = Tool.Result
type Result = Tool.NormalizedResult
type Limits = {
maxLines: number
@@ -64,8 +64,7 @@ const layer = Layer.effect(
const truncate = Effect.fnUntraced(function* (result: Result) {
if (result.metadata?.truncated !== undefined) return result
const content =
typeof result.content === "string" ? [{ type: "text" as const, text: result.content }] : (result.content ?? [])
const content = result.content
const text = content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n")
const limits = state.get()
const lines = text.split("\n")
+6 -1
View File
@@ -42,6 +42,11 @@ export interface Interface extends State.Transformable<Editor> {
readonly snapshot: (permissions?: Permission.Ruleset) => Effect.Effect<Snapshot>
}
/** A local execution result after hooks and content normalization. */
export interface NormalizedResult extends Tool.Result {
readonly content: ReadonlyArray<Tool.Content>
}
export interface Snapshot {
readonly definitions: ReadonlyArray<ToolDefinition>
readonly codeModeCatalog?: CodeModeCatalog.Inventory
@@ -53,7 +58,7 @@ export interface Snapshot {
readonly progress?: (update: Tool.Metadata) => Effect.Effect<void>
/** Surviving request definitions, keyed by the names advertised after session context hooks. */
readonly definitions?: ReadonlyMap<string, ToolDefinition>
}) => Effect.Effect<Tool.Result & { readonly content: ReadonlyArray<Tool.Content> }, Tool.Error>
}) => Effect.Effect<NormalizedResult, Tool.Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Tool") {}
+26
View File
@@ -93,6 +93,32 @@ describe("Catalog", () => {
}),
)
it.effect("preserves provider identity when updating new and existing providers", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = Provider.ID.make("original")
const renamed = Provider.ID.make("renamed")
yield* catalog.transform((editor) => {
editor.provider.update(providerID, (provider) => {
provider.id = renamed
provider.name = "Created"
})
expect(editor.provider.get(providerID)?.provider.id).toBe(providerID)
editor.provider.update(providerID, (provider) => {
provider.id = renamed
provider.name = "Updated"
})
})
expect(yield* catalog.provider.get(providerID)).toMatchObject({ id: providerID, name: "Updated" })
expect(yield* catalog.provider.get(renamed)).toBeUndefined()
expect((yield* catalog.provider.all()).map((provider) => provider.id)).toEqual([providerID])
yield* catalog.reload()
expect(yield* catalog.provider.get(providerID)).toMatchObject({ id: providerID, name: "Updated" })
}),
)
it.effect("derives availability from active credentials without changing provider state", () => {
const integrationID = Integration.ID.make("test")
const localCatalogLayer = Layer.fresh(
@@ -26,7 +26,9 @@ describe("ConfigToolOutputPlugin.Plugin", () => {
const plugins = yield* Plugin.Service
yield* ConfigToolOutputPlugin.Plugin.effect(yield* PluginHost.make(plugins))
expect((yield* output.truncate({ content: "one\ntwo" })).metadata?.truncated).toBe(true)
expect((yield* output.truncate({ content: [{ type: "text", text: "one\ntwo" }] })).metadata?.truncated).toBe(
true,
)
yield* config.setEntries([
new Document({
@@ -38,7 +40,7 @@ describe("ConfigToolOutputPlugin.Plugin", () => {
])
yield* bus.publish(Event.Updated, {})
for (let attempt = 0; attempt < 200; attempt++) {
const result = yield* output.truncate({ content: "one\ntwo" })
const result = yield* output.truncate({ content: [{ type: "text", text: "one\ntwo" }] })
if (result.metadata?.truncated === false) return
yield* Effect.sleep("10 millis")
}
+6
View File
@@ -0,0 +1,6 @@
import { ModelsDev } from "@opencode-ai/core/models-dev"
// Core is env-free, so the default ModelsDev node refreshes from models.dev
// unless the graph says otherwise. Real-Location fixtures opt out here; the
// test harness refuses any request that slips past.
export const offlineModels = ModelsDev.node.replace(ModelsDev.configured({ fetch: false }))
+2
View File
@@ -15,12 +15,14 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Formatter } from "../src/formatter"
import { Location } from "../src/location"
import { tempGlobalLayer } from "./fixture/global"
import { offlineModels } from "./fixture/models"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
Global.node.replace(tempGlobalLayer),
offlineModels,
]),
)
type ConfigInput = typeof Info.Encoded
@@ -14,6 +14,7 @@ import { Plugin } from "@opencode-ai/core/plugin"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { tmpdir } from "./fixture/tmpdir"
import { tempGlobalLayer } from "./fixture/global"
import { offlineModels } from "./fixture/models"
import { testEffect } from "./lib/effect"
import { Database } from "../src/database/database"
import { Bus } from "../src/bus"
@@ -47,6 +48,7 @@ const instances = Layer.effect(
)
const bindings: LayerNode.Replacements = [
Global.node.replace(tempGlobalLayer),
offlineModels,
LocationServiceMap.node.replace(Layer.succeed(LocationServiceMap.Service, map)),
Instance.node.replace(
Layer.succeed(Instance.Service, {
@@ -61,6 +63,7 @@ const instances = Layer.effect(
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node]), [
Global.node.replace(tempGlobalLayer),
offlineModels,
LocationServiceMap.node.replace(instances),
]),
)
+30 -1
View File
@@ -1,19 +1,48 @@
import { test, type TestOptions } from "bun:test"
import { Cause, Effect, Exit, Layer, type Scope } from "effect"
import { TestClock, TestConsole } from "effect/testing"
import { FetchHttpClient } from "effect/unstable/http"
type Body<A, E, R> = Effect.Effect<A, E, R> | (() => Effect.Effect<A, E, R>)
const body = <A, E, R>(value: Body<A, E, R>) => Effect.suspend(() => (typeof value === "function" ? value() : value))
const loopback = new Set(["127.0.0.1", "localhost", "[::1]"])
// Core is env-free, so nothing tells the default node graph to stay offline;
// a test that boots it would phone home through FetchHttpClient. Every
// FetchHttpClient reads this reference at request time, so refusing here covers
// each node that shares the default client, while an explicit HttpClient
// replacement never reaches it. Callers such as ModelsDev swallow request
// failures, so the harness also records the attempt and fails the test itself.
export const refuseNetwork = (violations: string[]): typeof fetch =>
Object.assign(
(input: string | URL | Request, init?: RequestInit) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
if (loopback.has(new URL(url).hostname)) return fetch(input, init)
const method = init?.method ?? (input instanceof Request ? input.method : "GET")
const message = `test attempted network request: ${method} ${url} — provide an explicit HttpClient or disable the fetch`
violations.push(message)
return Promise.reject(new Error(message))
},
{ preconnect: fetch.preconnect },
)
const run = <A, E, R, E2>(value: Body<A, E, R | Scope.Scope>, layer: Layer.Layer<R, E2>) =>
Effect.gen(function* () {
const exit = yield* body(value).pipe(Effect.scoped, Effect.provide(layer), Effect.exit)
const violations: string[] = []
const exit = yield* body(value).pipe(
Effect.scoped,
Effect.provide(layer),
Effect.provideService(FetchHttpClient.Fetch, refuseNetwork(violations)),
Effect.exit,
)
if (Exit.isFailure(exit)) {
for (const err of Cause.prettyErrors(exit.cause)) {
yield* Effect.logError(err)
}
}
if (violations.length > 0) return yield* Effect.fail(new Error(violations.join("\n")))
return yield* exit
}).pipe(Effect.runPromise)
@@ -24,6 +24,7 @@ import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { tmpdir } from "./fixture/tmpdir"
import { tempGlobalLayer } from "./fixture/global"
import { offlineModels } from "./fixture/models"
import { testEffect } from "./lib/effect"
import { toolDefinitions } from "./lib/tool"
import { Database } from "../src/database/database"
@@ -34,6 +35,7 @@ import { Tool } from "../src/tool"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node]), [
Global.node.replace(tempGlobalLayer),
offlineModels,
]),
)
const activityLocations = Layer.effect(
+73
View File
@@ -0,0 +1,73 @@
import { describe, expect, test } from "bun:test"
import { Effect, Layer } from "effect"
import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
import { it, refuseNetwork } from "./lib/effect"
describe("test harness network guard", () => {
test("refuses requests to hosts other than loopback", async () => {
const violations: string[] = []
const refused = refuseNetwork(violations)
await expect(refused("https://models.opencode.ai/api.json")).rejects.toThrow(
"test attempted network request: GET https://models.opencode.ai/api.json — provide an explicit HttpClient or disable the fetch",
)
await expect(refused(new Request("https://example.invalid/", { method: "POST" }))).rejects.toThrow(
"test attempted network request: POST https://example.invalid/",
)
expect(violations).toHaveLength(2)
})
it.live("the default http client node requests through the harness fetch", () =>
Effect.gen(function* () {
const seen: string[] = []
const response = yield* HttpClient.get("https://example.invalid/catalog").pipe(
Effect.flatMap((response) => response.text),
Effect.provide(AppNodeBuilder.build(LayerNodePlatform.httpClient)),
Effect.provideService(
FetchHttpClient.Fetch,
Object.assign(
(input: string | URL | Request) => {
seen.push(typeof input === "string" ? input : input instanceof URL ? input.href : input.url)
return Promise.resolve(new Response("from fetch"))
},
{ preconnect: fetch.preconnect },
),
),
)
expect(response).toBe("from fetch")
expect(seen).toEqual(["https://example.invalid/catalog"])
}),
)
it.live("an explicit HttpClient replacement is what the node sees", () =>
Effect.gen(function* () {
const mock = HttpClient.make((request) =>
Effect.succeed(HttpClientResponse.fromWeb(request, new Response("from mock"))),
)
const response = yield* HttpClient.get("https://example.invalid/catalog").pipe(
Effect.flatMap((response) => response.text),
Effect.provide(
AppNodeBuilder.build(LayerNodePlatform.httpClient, [
LayerNodePlatform.httpClient.replace(Layer.succeed(HttpClient.HttpClient, mock)),
]),
),
)
expect(response).toBe("from mock")
}),
)
it.live("loopback requests pass through to the real fetch", () =>
Effect.gen(function* () {
const server = yield* Effect.acquireRelease(
Effect.sync(() => Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response("local") })),
(server) => Effect.promise(() => server.stop(true)),
)
const response = yield* HttpClient.get(`http://127.0.0.1:${server.port}/`).pipe(
Effect.flatMap((response) => response.text),
Effect.provide(AppNodeBuilder.build(LayerNodePlatform.httpClient)),
)
expect(response).toBe("local")
}),
)
})
+14 -31
View File
@@ -162,7 +162,7 @@ describe("Npm.cacheKey", () => {
})
describe("Npm.add", () => {
test("resolves cached scoped package specs without reifying", async () => {
test("locates cached scoped package specs without reifying", async () => {
await using tmp = await tmpdir()
const spec = "@fixture/provider@1.0.0"
const directory = path.join(
@@ -185,24 +185,7 @@ describe("Npm.add", () => {
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
expect(entry.directory).toBe(directory)
expect(entry.entrypoint).toEndWith("/index.js")
})
test("falls back to the original spec when parsing fails", async () => {
await using tmp = await tmpdir()
const spec = "fixture provider"
const directory = path.join(tmp.path, "cache", "npm", Npm.sanitize(spec), "1000", "node_modules", spec)
await fs.mkdir(directory, { recursive: true })
await writePackage(directory, { name: spec, exports: "./index.js" })
await Bun.write(path.join(directory, "index.js"), "export const fixture = true\n")
const entry = await Effect.gen(function* () {
const npm = yield* Npm.Service
return yield* npm.add(spec)
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
expect(entry.directory).toBe(directory)
expect(entry.entrypoint).toEndWith("/index.js")
expect(entry.name).toBe("@fixture/provider")
})
test("reifies when package cache directory exists without the package installed", async () => {
@@ -224,16 +207,16 @@ describe("Npm.add", () => {
const entries = await Effect.gen(function* () {
const npm = yield* Npm.Service
return {
tui: yield* npm.add(spec, { subpaths: ["tui", ""] }),
fallback: yield* npm.add(spec, { subpaths: ["missing", ""] }),
added: yield* npm.add(spec),
cached: yield* npm.add(spec),
}
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
expect(entries.tui.entrypoint).toEndWith("/tui.js")
expect(entries.fallback.entrypoint).toEndWith("/index.js")
expect(await fs.stat(path.join(entries.added.directory, "package.json"))).toBeTruthy()
expect(entries.cached).toEqual(entries.added)
})
test.each(["unnamed", "named"])("installs and resolves %s Git packages with dependencies", async (kind) => {
test.each(["unnamed", "named"])("installs and locates %s Git packages with dependencies", async (kind) => {
await using tmp = await tmpdir()
const fixture = await createGitFixture(tmp.path)
const spec =
@@ -250,7 +233,7 @@ describe("Npm.add", () => {
}
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
expect(entries.added.entrypoint).toBe(pathToFileURL(path.join(entries.added.directory, "index.js")).href)
expect(entries.added.directory).toEndWith(path.join("node_modules", entries.added.name))
expect(entries.added.version).toBe(fixture.commit)
expect(entries.cached).toEqual(entries.added)
expect(entries.resolved).toEqual(entries.added)
@@ -271,7 +254,7 @@ describe("Npm.add", () => {
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
expect(entry.directory).toEndWith(path.join("node_modules", "fixture-subdirectory-plugin"))
expect(entry.entrypoint).toEndWith("/index.js")
expect(entry.name).toBe("fixture-subdirectory-plugin")
expect(
await fs.stat(path.join(path.dirname(entry.directory), "fixture-subdirectory-dependency", "package.json")),
).toBeTruthy()
@@ -347,16 +330,16 @@ describe("Npm.add", () => {
})
describe("Npm.resolve", () => {
test("resolves a TUI entrypoint only when the package is already cached", async () => {
test("locates a cached package without installing it", async () => {
await using tmp = await tmpdir()
const cache = path.join(tmp.path, "cache")
const spec = "fixture-plugin@1.0.0"
const directory = path.join(cache, "npm", Npm.sanitize(spec), "1000", "node_modules", "fixture-plugin")
const missing = await Effect.gen(function* () {
const npm = yield* Npm.Service
return yield* npm.resolve(spec, { subpaths: ["tui"] })
return yield* npm.resolve(spec)
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
expect(missing.entrypoint).toBeUndefined()
expect(missing.version).toBeUndefined()
await fs.mkdir(directory, { recursive: true })
await writePackage(directory, {
@@ -368,9 +351,9 @@ describe("Npm.resolve", () => {
const resolved = await Effect.gen(function* () {
const npm = yield* Npm.Service
return yield* npm.resolve(spec, { subpaths: ["tui"] })
return yield* npm.resolve(spec)
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
expect(resolved.entrypoint).toEndWith("/tui.js")
expect(resolved.directory).toBe(directory)
expect(resolved.version).toBe("1.0.0")
})
})
+42
View File
@@ -154,6 +154,48 @@ it.effect("reports a failed plugin without blocking a healthy plugin", () =>
}),
)
it.effect("keeps the suffix after a failed plugin alive across identical activations", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const setups = { good1: 0, broken: 0, good2: 0 }
const good = (id: "good1" | "good2"): Plugin.Generation => ({
id,
revision: "1",
effect: () => Effect.sync(() => void setups[id]++),
})
const broken = (revision: string): Plugin.Generation => ({
id: "broken",
revision,
effect: () =>
Effect.suspend(() => {
setups.broken++
return Effect.die(new Error("Setup failed"))
}),
})
const failed = () =>
plugins.list().pipe(Effect.map((inventory) => inventory.find((plugin) => plugin.id === "broken")?.state))
yield* plugins.activate([good("good1"), broken("1"), good("good2")])
expect(setups).toEqual({ good1: 1, broken: 1, good2: 1 })
expect(yield* failed()).toMatchObject({ status: "failed", error: expect.stringContaining("Setup failed") })
// Identical definitions: nothing restarts and the failed revision is not retried.
yield* plugins.activate([good("good1"), broken("1"), good("good2")])
expect(setups).toEqual({ good1: 1, broken: 1, good2: 1 })
expect(yield* failed()).toMatchObject({ status: "failed", error: expect.stringContaining("Setup failed") })
expect((yield* plugins.list()).map((plugin) => `${plugin.id}:${plugin.state.status}`)).toEqual([
"good1:active",
"broken:failed",
"good2:active",
])
// A new revision of the failed plugin is retried once, restarting only the suffix behind it.
yield* plugins.activate([good("good1"), broken("2"), good("good2")])
expect(setups).toEqual({ good1: 1, broken: 2, good2: 2 })
expect(yield* failed()).toMatchObject({ status: "failed" })
}),
)
it.effect("reloading a plugin replaces its command implementation", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
+3 -3
View File
@@ -39,10 +39,10 @@ import { emptyMcpLayer } from "../fixture/mcp"
const npmLayer = Layer.succeed(
Npm.Service,
Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
add: (name) => Effect.succeed({ directory: "", name }),
resolve: (name) => Effect.succeed({ directory: "", name }),
check: () => Effect.succeed(false),
update: () => Effect.succeed({ directory: "", entrypoint: undefined }),
update: (name) => Effect.succeed({ directory: "", name }),
which: () => Effect.undefined,
}),
)
+310 -1
View File
@@ -1,7 +1,8 @@
import path from "path"
import { describe, expect } from "bun:test"
import { Money } from "@opencode-ai/schema/money"
import { Effect, Exit, Layer, Scope } from "effect"
import { Context, Effect, Exit, Layer, Scope } from "effect"
import { TestClock } from "effect/testing"
import { Catalog } from "@opencode-ai/core/catalog"
import { Integration } from "@opencode-ai/core/integration"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -31,10 +32,245 @@ const layer = AppNodeBuilder.build(LayerNode.group([Catalog.node, Integration.no
])
const it = testEffect(layer)
const real = testEffect(PluginTestLayer)
const isolated = testEffect(Layer.empty)
const models = (file: string) =>
AppNodeBuilder.build(ModelsDev.node, [ModelsDev.node.replace(ModelsDev.configured({ file, fetch: false }))])
function required<T>(value: T | undefined): T {
if (value === undefined) throw new Error("Expected value")
return value
}
// One complete Location graph behind the production plugin host. Two of these stand in for
// two Locations that share a single models.dev snapshot instance.
const owner = Effect.gen(function* () {
const context = yield* Layer.build(PluginTestLayer)
const host = yield* PluginHost.make(Context.get(context, Plugin.Service)).pipe(Effect.provideContext(context))
return {
context,
host,
bus: Context.get(context, Bus.Service),
catalog: Context.get(context, Catalog.Service),
integration: Context.get(context, Integration.Service),
}
})
// Every nested overlay shape the normalized snapshot can carry, so in-place mutation of any
// existing nested value is observable on the source object.
const richSnapshot = (name = "Acme") => {
const providerID = Provider.ID.make("acme")
const modelID = Model.ID.make("gpt-5.4")
const snapshot = [
{
info: {
id: providerID,
name,
activation: "auto",
package: Provider.aisdk("@ai-sdk/openai-compatible"),
settings: { baseURL: "https://api.acme.test/v1", thinking: { type: "adaptive", display: "summarized" } },
headers: { "x-acme": "provider" },
body: { service_tier: "default", tags: ["stable"] },
},
environment: ["ACME_API_KEY", "ACME_HOST"],
models: [
{
id: modelID,
modelID,
providerID,
name: "GPT-5.4",
family: Model.Family.make("gpt"),
settings: { baseURL: "https://models.acme.test/v1", reasoning: { effort: "low" } },
headers: { "x-mode": "fast" },
body: { service_tier: "priority", options: { top_k: 1 }, stop: ["<end>"] },
capabilities: { tools: true, input: ["text"], output: ["text"] },
variants: [
{
id: Model.VariantID.make("low"),
settings: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" },
body: { max_tokens: 1024 },
},
],
time: { released: Date.parse("2026-01-01") },
cost: [
{
input: Money.USDPerMillionTokens.make(2.5),
output: Money.USDPerMillionTokens.make(15),
cache: { read: Money.USDPerMillionTokens.make(0.25), write: Money.USDPerMillionTokens.zero },
},
{
tier: { type: "context", size: 200_000 },
input: Money.USDPerMillionTokens.make(5),
output: Money.USDPerMillionTokens.make(22.5),
cache: { read: Money.USDPerMillionTokens.make(0.5), write: Money.USDPerMillionTokens.zero },
},
],
status: "active",
enabled: true,
limit: { context: 1_050_000, input: 922_000, output: 128_000 },
},
],
},
] satisfies readonly ModelsDev.Snapshot[]
return { providerID, modelID, snapshot }
}
describe("ModelsDevPlugin", () => {
isolated.effect("shares one snapshot between Locations while each catalog mutates only its own copies", () =>
Effect.gen(function* () {
const { providerID, modelID, snapshot } = richSnapshot()
const pristine = JSON.stringify(snapshot)
const source = ModelsDev.Service.of({ get: () => Effect.succeed(snapshot), refresh: () => Effect.void })
const first = yield* owner
const second = yield* owner
for (const each of [first, second])
yield* ModelsDevPlugin.effect(each.host).pipe(
Effect.provideService(ModelsDev.Service, source),
Effect.provideContext(each.context),
)
// The stored environment method must own its names array; the source array is shared.
let names: readonly string[] | undefined
yield* first.integration.transform((draft) => {
names = draft.method
.list(Integration.ID.make(providerID))
.flatMap((method) => (method.type === "env" ? [method.names] : []))[0]
})
expect(names).toEqual(snapshot[0].environment)
expect(names).not.toBe(snapshot[0].environment)
// Catalog-owned provider records are copies, not the source's nested objects.
const stored = required(yield* first.catalog.provider.get(providerID))
expect(stored.settings).toEqual(snapshot[0].info.settings)
expect(stored.settings).not.toBe(snapshot[0].info.settings)
expect(stored.headers).not.toBe(snapshot[0].info.headers)
expect(stored.body).not.toBe(snapshot[0].info.body)
// A later plugin in the first Location mutates existing nested values in place through the
// production host, the way the Bedrock, Azure, and config provider plugins do.
const scope = yield* Scope.make()
yield* first.host.catalog
.transform((catalog) => {
catalog.provider.update(providerID, (provider) => {
required(provider.settings).baseURL = "https://override.acme.test/v1"
required(provider.settings).thinking.type = "disabled"
required(provider.headers)["x-acme"] = "override"
required(provider.body).service_tier = "priority"
required(provider.body).tags.push("override")
})
catalog.model.update(providerID, modelID, (model) => {
required(model.settings).reasoning.effort = "high"
required(model.headers)["x-mode"] = "slow"
required(model.body).options.top_k = 7
required(model.body).stop.push("<stop>")
const variant = required(model.variants[0])
required(variant.settings).thinking.type = "disabled"
required(variant.body).max_tokens = 4096
model.capabilities.input.push("image")
required(model.cost[0]).cache.read = Money.USDPerMillionTokens.make(9)
required(required(model.cost[1]).tier).size = 1
required(model.cost[1]).input = Money.USDPerMillionTokens.make(42)
model.limit.context = 1
model.time.released = 5
})
})
.pipe(Scope.provide(scope))
const mutatedProvider = required(yield* first.catalog.provider.get(providerID))
const mutated = required(yield* first.catalog.model.get(providerID, modelID))
expect(mutatedProvider.settings).toEqual({
baseURL: "https://override.acme.test/v1",
thinking: { type: "disabled", display: "summarized" },
})
expect(mutatedProvider.headers).toEqual({ "x-acme": "override" })
expect(mutatedProvider.body).toEqual({ service_tier: "priority", tags: ["stable", "override"] })
expect(mutated.settings).toEqual({
baseURL: "https://models.acme.test/v1",
thinking: { type: "disabled", display: "summarized" },
reasoning: { effort: "high" },
})
expect(mutated.headers).toEqual({ "x-acme": "override", "x-mode": "slow" })
expect(mutated.body).toEqual({
service_tier: "priority",
tags: ["stable", "override"],
options: { top_k: 7 },
stop: ["<end>", "<stop>"],
})
expect(mutated.variants).toEqual([
{
id: Model.VariantID.make("low"),
settings: { thinking: { type: "disabled", display: "summarized" }, effort: "low" },
body: { max_tokens: 4096 },
},
])
expect(mutated.capabilities.input).toEqual(["text", "image"])
expect(mutated.cost[0]?.cache.read).toBe(Money.USDPerMillionTokens.make(9))
expect(mutated.cost[1]).toMatchObject({ tier: { type: "context", size: 1 }, input: 42 })
expect(mutated.limit.context).toBe(1)
expect(mutated.time.released).toBe(5)
// The sibling Location and the shared source are untouched.
const sibling = required(yield* second.catalog.model.get(providerID, modelID))
expect(sibling.settings).toEqual({
baseURL: "https://models.acme.test/v1",
thinking: { type: "adaptive", display: "summarized" },
reasoning: { effort: "low" },
})
expect(sibling.headers).toEqual({ "x-acme": "provider", "x-mode": "fast" })
expect(sibling.body).toEqual({
service_tier: "priority",
tags: ["stable"],
options: { top_k: 1 },
stop: ["<end>"],
})
expect(sibling.variants).toEqual(snapshot[0].models[0].variants)
expect(sibling.capabilities.input).toEqual(["text"])
expect(sibling.cost).toEqual(snapshot[0].models[0].cost)
expect(sibling.limit).toEqual(snapshot[0].models[0].limit)
expect(sibling.time).toEqual(snapshot[0].models[0].time)
expect(required(yield* second.catalog.provider.get(providerID)).settings).toEqual(snapshot[0].info.settings)
expect(JSON.stringify(snapshot)).toBe(pristine)
// Removing the mutating transform rebuilds the first catalog from the shared source.
yield* Scope.close(scope, Exit.void)
expect(yield* first.catalog.model.get(providerID, modelID)).toEqual(sibling)
expect(required(yield* first.catalog.provider.get(providerID)).settings).toEqual(snapshot[0].info.settings)
expect(JSON.stringify(snapshot)).toBe(pristine)
}),
)
isolated.effect("replaces its snapshot reference on refresh without touching the sibling Location", () =>
Effect.gen(function* () {
const initial = richSnapshot("Acme")
const refreshed = richSnapshot("Acme Refreshed")
const pristine = JSON.stringify(initial.snapshot)
const current = { snapshot: initial.snapshot }
const source = ModelsDev.Service.of({
get: () => Effect.sync(() => current.snapshot),
refresh: () => Effect.void,
})
const first = yield* owner
const second = yield* owner
for (const each of [first, second])
yield* ModelsDevPlugin.effect(each.host).pipe(
Effect.provideService(ModelsDev.Service, source),
Effect.provideContext(each.context),
)
expect(required(yield* first.catalog.provider.get(initial.providerID)).name).toBe("Acme")
current.snapshot = refreshed.snapshot
yield* first.bus.publish(ModelsDev.Event.Refreshed, {})
// Integration and catalog reloads are debounced sequentially.
yield* TestClock.adjust("500 millis")
yield* TestClock.adjust("500 millis")
yield* TestClock.adjust("500 millis")
expect(required(yield* first.catalog.provider.get(initial.providerID)).name).toBe("Acme Refreshed")
expect(required(yield* second.catalog.provider.get(initial.providerID)).name).toBe("Acme")
expect(JSON.stringify(initial.snapshot)).toBe(pristine)
expect(JSON.stringify(refreshed.snapshot)).toBe(JSON.stringify(richSnapshot("Acme Refreshed").snapshot))
}),
)
real.effect("keeps the retained model seed unchanged across catalog replay", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
@@ -92,6 +328,79 @@ describe("ModelsDevPlugin", () => {
}),
)
it.effect("keeps the shared models.dev snapshot pristine while catalog transforms mutate records in place", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const catalog = yield* Catalog.Service
const providerID = Provider.ID.make("acme")
const modelID = Model.ID.make("gpt-5.4")
const snapshot = [
{
info: {
id: providerID,
name: "Acme",
activation: "auto",
package: Provider.aisdk("@ai-sdk/openai-compatible"),
settings: { baseURL: "https://api.acme.test/v1" },
headers: { "x-acme": "provider" },
},
environment: ["ACME_API_KEY"],
models: [
{
id: modelID,
modelID,
providerID,
name: "GPT-5.4",
settings: { baseURL: "https://models.acme.test/v1" },
capabilities: { tools: true, input: ["text"], output: ["text"] },
variants: [],
time: { released: Date.parse("2026-01-01") },
cost: [],
status: "active",
enabled: true,
limit: { context: 1_050_000, output: 128_000 },
},
],
},
] satisfies readonly ModelsDev.Snapshot[]
const pristine = JSON.stringify(snapshot)
// The plugin receives the same snapshot instance every Location shares.
yield* ModelsDevPlugin.effect(
host({
catalog: catalogHost(catalog),
integration: integrationHost(integrations),
}),
).pipe(
Effect.provideService(
ModelsDev.Service,
ModelsDev.Service.of({ get: () => Effect.succeed(snapshot), refresh: () => Effect.void }),
),
)
// Later plugins mutate nested provider and model records in place, as the Bedrock provider does.
yield* catalog.transform((draft) => {
draft.provider.update(providerID, (provider) => {
if (provider.settings) provider.settings.baseURL = "https://override.acme.test/v1"
if (provider.headers) provider.headers["x-acme"] = "override"
})
draft.model.update(providerID, modelID, (model) => {
if (model.settings) model.settings.baseURL = "https://override.models.acme.test/v1"
model.variants.push({ id: Model.VariantID.make("configured") })
model.capabilities.input.push("image")
})
})
const provider = yield* catalog.provider.get(providerID)
const model = yield* catalog.model.get(providerID, modelID)
expect(provider?.settings?.baseURL).toBe("https://override.acme.test/v1")
expect(provider?.headers).toEqual({ "x-acme": "override" })
expect(model?.settings?.baseURL).toBe("https://override.models.acme.test/v1")
expect(model?.variants).toEqual([{ id: Model.VariantID.make("configured") }])
expect(model?.capabilities.input).toEqual(["text", "image"])
expect(JSON.stringify(snapshot)).toBe(pristine)
}),
)
it.effect("projects normalized models.dev snapshots into the catalog", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
+1 -1
View File
@@ -166,7 +166,7 @@ describe("plan plugin reminders", () => {
const { persisted } = yield* run([agentSelected(plan, build), agentSelected(build, plan)])
yield* settle(persisted, 2)
expect(persisted[0]).toContain("You are in Plan mode")
expect(persisted[0]).toContain("optionally create or update plan documents")
expect(persisted[0]).toContain("Do not create or update plan files unless the user explicitly asks you to")
expect(persisted[0]).toContain(planDirectory)
expect(persisted[0]).toContain("Do not modify any other files")
expect(persisted[1]).toContain("NO LONGER in Plan mode")
@@ -4,7 +4,6 @@ import { Cause, Effect, Layer } from "effect"
import fs from "fs/promises"
import os from "os"
import path from "path"
import { fileURLToPath } from "url"
import { AISDK } from "@opencode-ai/core/aisdk"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Model } from "@opencode-ai/core/model"
@@ -16,16 +15,15 @@ import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href
const fixtureProviderPath = fileURLToPath(fixtureProvider)
const it = testEffect(PluginTestLayer)
const itWithAISDK = testEffect(Layer.mergeAll(PluginTestLayer, AppNodeBuilder.build(AISDK.node)))
function npmEntrypoint(entrypoint?: string) {
function npmPackage(directory = "") {
return Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint }),
resolve: () => Effect.succeed({ directory: "", entrypoint }),
add: (name) => Effect.succeed({ directory, name }),
resolve: (name) => Effect.succeed({ directory, name }),
check: () => Effect.succeed(false),
update: () => Effect.succeed({ directory: "", entrypoint }),
update: (name) => Effect.succeed({ directory, name }),
which: () => Effect.undefined,
})
}
@@ -36,13 +34,16 @@ const addPlugin = Effect.fn(function* (npm?: Npm.Interface) {
yield* DynamicProviderPlugin.effect(host).pipe(Effect.provideService(Npm.Service, npm ?? (yield* Npm.Service)))
})
function tempEntrypoint(source: string) {
function tempPackage(source: string) {
return Effect.acquireRelease(
Effect.promise(async () => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-provider-dynamic-"))
const entrypoint = path.join(directory, "provider.mjs")
await Bun.write(entrypoint, source)
return { directory, entrypoint }
await Bun.write(path.join(directory, "provider.mjs"), source)
await Bun.write(
path.join(directory, "package.json"),
JSON.stringify({ name: "fixture-provider", exports: "./provider.mjs" }),
)
return { directory }
}),
(tmp) => Effect.promise(() => fs.rm(tmp.directory, { recursive: true, force: true })),
)
@@ -86,27 +87,11 @@ describe("DynamicProviderPlugin", () => {
}),
)
it.effect("injects the provider ID as the SDK factory name", () =>
it.live("loads npm packages through their resolved import entrypoint", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("custom-provider"), Model.ID.make("test-model")),
modelID: Model.ID.make("test-model"),
package: Provider.aisdk(fixtureProvider),
}),
package: fixtureProvider,
options: { name: "custom-provider", marker: "dynamic" },
})
expect(result.sdk.options).toEqual({ marker: "dynamic", name: "custom-provider" })
}),
)
it.effect("loads npm packages through their resolved import entrypoint", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* addPlugin(npmEntrypoint(fixtureProviderPath))
const tmp = yield* tempPackage(`export { createFixtureProvider } from ${JSON.stringify(fixtureProvider)}\n`)
yield* addPlugin(npmPackage(tmp.directory))
const result = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("npm-provider"), Model.ID.make("test-model")),
@@ -123,7 +108,7 @@ describe("DynamicProviderPlugin", () => {
itWithAISDK.effect("wraps missing npm entrypoint failures as AISDK init errors", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* addPlugin(npmEntrypoint())
yield* addPlugin(npmPackage())
const exit = yield* aisdk
.language(
Model.Info.make({
@@ -159,8 +144,8 @@ describe("DynamicProviderPlugin", () => {
itWithAISDK.live("wraps missing provider factory exports as AISDK init errors", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
const tmp = yield* tempEntrypoint("export const notAProviderFactory = true\n")
yield* addPlugin(npmEntrypoint(tmp.entrypoint))
const tmp = yield* tempPackage("export const notAProviderFactory = true\n")
yield* addPlugin(npmPackage(tmp.directory))
const exit = yield* aisdk
.language(
Model.Info.make({
@@ -3,6 +3,7 @@ import { LLM } from "@opencode-ai/ai"
import { LLMClient, RequestExecutor } from "@opencode-ai/ai/route"
import { Money } from "@opencode-ai/schema/money"
import { Effect, Layer, Stream } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
@@ -608,7 +609,16 @@ describe("OpencodePlugin", () => {
draft.cost = cost(1)
})
})
yield* addPlugin()
// An env credential has no server metadata, so the plugin would ask the
// default Console for remote config; answer 404 (no remote config) locally.
yield* addPlugin().pipe(
Effect.provideService(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 404 }))),
),
),
)
expect(required(yield* catalog.provider.get(Provider.ID.opencode)).settings?.apiKey).toBeUndefined()
expect(required(yield* catalog.model.get(Provider.ID.opencode, Model.ID.make("paid"))).enabled).toBe(true)
}),
@@ -14,10 +14,10 @@ import { PluginTestLayer } from "./fixture"
const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href
const it = testEffect(PluginTestLayer)
const npm = Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
add: (name) => Effect.succeed({ directory: "", name }),
resolve: (name) => Effect.succeed({ directory: "", name }),
check: () => Effect.succeed(false),
update: () => Effect.succeed({ directory: "", entrypoint: undefined }),
update: (name) => Effect.succeed({ directory: "", name }),
which: () => Effect.undefined,
})
@@ -0,0 +1,95 @@
import { expect } from "bun:test"
import { Cause, Effect, Exit } from "effect"
import { Plugin } from "@opencode-ai/core/plugin"
import { Skill } from "@opencode-ai/core/skill"
import { AbsolutePath } from "@opencode-ai/schema/schema"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const skill = {
id: Skill.ID.make("review"),
name: Skill.Name.make("Review"),
description: "Review changes",
location: AbsolutePath.make("/fixture/review.md"),
content: "Review changes",
}
for (const cause of [new TypeError("synthetic-private-detail"), "synthetic-private-detail"]) {
it.effect(`attributes a deferred skill transform throwing ${typeof cause}`, () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const skills = yield* Skill.Service
let setup = false
const activation = yield* plugins
.activate([
{
id: "healthy",
revision: "1",
effect: (ctx) => ctx.skill.transform((editor) => editor.add(skill)).pipe(Effect.asVoid),
},
{
id: "broken-skills",
revision: "1",
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.skill.transform((editor) => {
editor.remove(skill.id)
throw cause
})
setup = true
}),
},
])
.pipe(Effect.exit)
expect(setup).toBe(true)
// Neither a partial fold nor the old value is returned after failure. Every read retries.
for (const exit of [
activation,
yield* skills.list().pipe(Effect.asVoid, Effect.exit),
yield* skills.get(skill.id).pipe(Effect.asVoid, Effect.exit),
]) {
if (Exit.isSuccess(exit)) throw new Error("Expected a failed skill fold")
expect(Cause.hasFails(exit.cause)).toBe(false)
expect(Cause.squash(exit.cause)).toMatchObject({
_tag: "PluginCallbackError",
pluginID: "broken-skills",
operation: "skill.transform",
message: 'Plugin "broken-skills" failed during skill.transform.',
cause,
})
}
// Only an explicit registration change removes the failure; nothing is silently disabled.
yield* plugins.activate([
{
id: "healthy",
revision: "1",
effect: (ctx) => ctx.skill.transform((editor) => editor.add(skill)).pipe(Effect.asVoid),
},
])
expect(yield* skills.list()).toEqual([skill])
}),
)
}
it.effect("keeps setup failures distinct from deferred callback failures", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const skills = yield* Skill.Service
yield* plugins.activate([
{ id: "setup-failure", revision: "1", effect: () => Effect.die(new Error("fixture setup failed")) },
{
id: "healthy",
revision: "1",
effect: (ctx) => ctx.skill.transform((editor) => editor.add(skill)).pipe(Effect.asVoid),
},
])
expect((yield* plugins.list()).find((plugin) => plugin.id === "setup-failure")?.state).toMatchObject({
status: "failed",
error: expect.stringContaining("fixture setup failed"),
})
expect(yield* skills.list()).toEqual([skill])
}),
)
@@ -0,0 +1,258 @@
import { describe, expect, setDefaultTimeout } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Deferred, Duration, Effect, Fiber, Layer, LayerMap, Schedule } from "effect"
import { TestClock } from "effect/testing"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Event } from "@opencode-ai/schema/config"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { Npm } from "@opencode-ai/util/npm"
import { Bus } from "@opencode-ai/core/bus"
import { Command } from "@opencode-ai/core/command"
import { Database } from "@opencode-ai/core/database/database"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { Instance } from "@opencode-ai/core/instance"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { Location } from "@opencode-ai/core/location"
import { Plugin } from "@opencode-ai/core/plugin"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { tempGlobalLayer } from "../fixture/global"
import { offlineModels } from "../fixture/models"
import { tmpdirScoped } from "../fixture/tmpdir"
import { advance } from "../lib/clock"
import { testEffect } from "../lib/effect"
// Real Location boot with plugin-directory discovery, so local plugin files are loaded and reloaded.
setDefaultTimeout(15_000)
// Package resolution can be held open so overlapping activations become observable.
const npm = {
directory: "",
gate: undefined as Deferred.Deferred<void> | undefined,
inflight: 0,
peak: 0,
}
const npmLayer = Layer.succeed(
Npm.Service,
Npm.Service.of({
add: (name) => Effect.succeed({ directory: npm.directory, name }),
resolve: (name) =>
Effect.gen(function* () {
npm.inflight++
npm.peak = Math.max(npm.peak, npm.inflight)
if (npm.gate) yield* Deferred.await(npm.gate)
npm.inflight--
return { directory: npm.directory, name }
}),
check: () => Effect.succeed(false),
update: (name) => Effect.succeed({ directory: npm.directory, name }),
which: () => Effect.undefined,
}),
)
const instances = Layer.effect(
LocationServiceMap.Service,
Effect.gen(function* () {
const watcher = yield* Watcher.Test
const map = yield* LayerMap.make((ref: Location.Ref) => Instance.layer(ref, { replacements: bindings }), {
idleTimeToLive: Duration.infinity,
})
const bindings: LayerNode.Replacements = [
Global.node.replace(tempGlobalLayer),
offlineModels,
Npm.node.replace(npmLayer),
Watcher.node.replace(Layer.succeed(Watcher.Service, watcher)),
LocationServiceMap.node.replace(Layer.succeed(LocationServiceMap.Service, map)),
Instance.node.replace(
Layer.succeed(Instance.Service, {
provide: (session) => Effect.provide(map.get(session.location)),
}),
),
]
return map
}),
).pipe(Layer.provide(Watcher.testLayer))
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
Global.node.replace(tempGlobalLayer),
offlineModels,
LocationServiceMap.node.replace(instances),
]).pipe(Layer.provideMerge(Watcher.testLayer)),
)
const greeter = (command: string) => `export default {
id: "greeter",
async setup(ctx) {
await ctx.command.transform((editor) => editor.add({ name: "${command}", execute: async () => {} }))
},
}`
// Real-time polling: reloads do filesystem work that the TestClock cannot advance.
const settle = (predicate: () => boolean, attempts = 200): Effect.Effect<void, string> =>
Effect.suspend(() => {
if (predicate()) return Effect.void
if (attempts === 0) return Effect.fail("not settled")
return Effect.promise(() => Bun.sleep(10)).pipe(Effect.andThen(settle(predicate, attempts - 1)))
})
const failed = (plugins: Plugin.Interface) =>
plugins.list().pipe(
Effect.flatMap((inventory) => {
const failure = inventory.find((plugin) => plugin.state.status === "failed" && plugin.source.type === "local")
return failure ? Effect.succeed(failure) : Effect.fail("activation pending")
}),
Effect.retry({ times: 200, schedule: Schedule.spaced("25 millis") }),
)
describe("PluginSupervisor reload", () => {
;(["discovered", "configured"] as const).forEach((mode) => {
it.effect(`retains a ${mode} plugin change during initial activation`, () =>
Effect.gen(function* () {
const directory = yield* tmpdirScoped()
const file = path.join(
directory.path,
mode === "discovered" ? ".opencode/plugins/greeter.ts" : "external/greeter/index.ts",
)
yield* Effect.promise(async () => {
await Bun.write(file, greeter("greet-v1"))
await fs.utimes(file, new Date(0), new Date(0))
if (mode === "configured") {
await Bun.write(
path.join(directory.path, ".opencode/opencode.json"),
JSON.stringify({ plugins: [path.dirname(file)] }),
)
}
})
const entered = yield* Deferred.make<void>()
const gate = yield* Deferred.make<void>()
const sdk = yield* SdkPlugins.Service
yield* sdk.register(
define({
id: "gated",
effect: () => Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(gate))),
}),
)
const watcher = yield* Watcher.Test
const locations = yield* LocationServiceMap.Service
yield* Effect.gen(function* () {
const plugins = yield* Plugin.Service
const commands = yield* Command.Service
yield* Deferred.await(entered)
// The real ConfigPluginSource merges config-root changes and configured-path watches.
// Emit while setup is blocked, without a bus event that could mask a lost source trigger.
yield* Effect.promise(async () => {
await Bun.write(file, greeter("greet-v2"))
await fs.utimes(file, new Date(), new Date())
})
yield* watcher.emit({ path: file, type: "update" })
const ready = yield* plugins.awaitActivation.pipe(Effect.forkScoped({ startImmediately: true }))
yield* Deferred.succeed(gate, undefined)
yield* advance(() => ready.pollUnsafe() !== undefined)
yield* Fiber.join(ready)
expect(yield* commands.get("greet-v1")).toBeUndefined()
expect(yield* commands.get("greet-v2")).toBeDefined()
}).pipe(
Effect.scoped,
Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory.path) }))),
)
}),
)
})
it.live("keeps the running generation when an updated local plugin fails to import", () =>
Effect.gen(function* () {
const directory = yield* tmpdirScoped()
const file = path.join(directory.path, ".opencode/plugins/greeter.ts")
// Local plugin revisions key on mtime, so give each rewrite a distinct timestamp.
const write = (content: string, mtime: Date) =>
Effect.promise(async () => {
await Bun.write(file, content)
await fs.utimes(file, mtime, mtime)
})
yield* write(greeter("greet-v1"), new Date(Date.now() - 60_000))
const bus = yield* Bus.Service
const locations = yield* LocationServiceMap.Service
yield* Effect.gen(function* () {
const plugins = yield* Plugin.Service
const commands = yield* Command.Service
yield* plugins.awaitActivation
expect(yield* commands.get("greet-v1")).toBeDefined()
yield* write("export default {", new Date())
yield* bus.publish(Event.Updated, {})
const failure = yield* failed(plugins)
expect(failure).toMatchObject({ source: { type: "local", path: file }, state: { status: "failed" } })
// The broken revision never produced a generation, so the previous one keeps running.
expect(yield* commands.get("greet-v1")).toBeDefined()
expect(yield* plugins.list()).toContainEqual(
expect.objectContaining({
id: "greeter",
source: { type: "local", path: file },
state: { status: "active" },
}),
)
}).pipe(
Effect.scoped,
Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory.path) }))),
)
}),
)
it.effect("serializes the periodic refresh behind an in-flight reload", () =>
Effect.gen(function* () {
const directory = yield* tmpdirScoped()
npm.directory = path.join(directory.path, "fixture-pkg")
npm.gate = undefined
npm.peak = 0
yield* Effect.promise(() =>
Bun.write(
path.join(npm.directory, "package.json"),
JSON.stringify({ name: "fixture-pkg", exports: { "./server": "./server.ts" } }),
),
)
yield* Effect.promise(() => Bun.write(path.join(npm.directory, "server.ts"), greeter("greet-pkg")))
yield* Effect.promise(() =>
Bun.write(path.join(directory.path, ".opencode/opencode.json"), JSON.stringify({ plugins: ["fixture-pkg"] })),
)
const bus = yield* Bus.Service
const locations = yield* LocationServiceMap.Service
yield* Effect.gen(function* () {
const plugins = yield* Plugin.Service
const commands = yield* Command.Service
yield* TestClock.adjust("100 millis")
yield* plugins.awaitActivation
expect(yield* commands.get("greet-pkg")).toBeDefined()
expect(npm.peak).toBe(1)
// Hold the next resolve open, then let the 24 hour refresh fire while it is blocked.
const gate = yield* Deferred.make<void>()
npm.gate = gate
npm.inflight = 0
npm.peak = 0
yield* bus.publish(Event.Updated, {})
yield* TestClock.adjust("100 millis")
yield* settle(() => npm.inflight === 1)
yield* TestClock.adjust("24 hours")
// Without serialization the refresh resolves concurrently with the held reload and the peak reaches 2.
yield* settle(() => npm.peak > 1, 50).pipe(Effect.ignore)
const peak = npm.peak
yield* Deferred.succeed(gate, undefined)
npm.gate = undefined
yield* TestClock.adjust("100 millis")
yield* plugins.awaitActivation
expect(peak).toBe(1)
}).pipe(
Effect.scoped,
Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory.path) }))),
)
}),
)
})
+137 -3
View File
@@ -1,10 +1,12 @@
import { describe, expect } from "bun:test"
import { Duration, Effect, Layer, LayerMap } from "effect"
import { Deferred, Duration, Effect, Layer, LayerMap, Stream } from "effect"
import { TestClock } from "effect/testing"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { Command } from "@opencode-ai/core/command"
import { ConfigPluginSource } from "@opencode-ai/core/config/plugin/source"
import { Instance } from "@opencode-ai/core/instance"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { Location } from "@opencode-ai/core/location"
@@ -14,19 +16,35 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { Database } from "../../src/database/database"
import { Bus } from "../../src/bus"
import { tempGlobalLayer } from "../fixture/global"
import { offlineModels } from "../fixture/models"
import { tmpdirScoped } from "../fixture/tmpdir"
import { advance } from "../lib/clock"
import { testEffect } from "../lib/effect"
const id = Plugin.ID.make("account-prompts")
// Host and instance plugins share one ID; each registers a distinct command so the winner is observable.
const greeter = (command: string) =>
const greeter = (command: string, plugin: string = id) =>
define({
id,
id: plugin,
effect: (ctx) =>
ctx.command.transform((editor) => editor.add({ name: command, execute: () => Effect.void })).pipe(Effect.asVoid),
})
// Every supervisor activation scans the config plugin operations once, so counting scans counts activations.
const source = { activations: 0 }
const sourceLayer = Layer.succeed(
ConfigPluginSource.Service,
ConfigPluginSource.Service.of({
operations: () =>
Effect.sync(() => {
source.activations++
return []
}),
changes: () => Stream.never,
}),
)
const instances = Layer.effect(
LocationServiceMap.Service,
Effect.gen(function* () {
@@ -37,6 +55,8 @@ const instances = Layer.effect(
)
const bindings: LayerNode.Replacements = [
Global.node.replace(tempGlobalLayer),
offlineModels,
ConfigPluginSource.node.replace(sourceLayer),
LocationServiceMap.node.replace(Layer.succeed(LocationServiceMap.Service, map)),
Instance.node.replace(
Layer.succeed(Instance.Service, {
@@ -51,6 +71,7 @@ const instances = Layer.effect(
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
Global.node.replace(tempGlobalLayer),
offlineModels,
LocationServiceMap.node.replace(instances),
]),
)
@@ -94,4 +115,117 @@ describe("PluginSupervisor", () => {
).toBe(true)
}),
)
it.effect("activates the initial generation without waiting for the reload debounce", () =>
Effect.gen(function* () {
source.activations = 0
const directory = yield* tmpdirScoped()
const locations = yield* LocationServiceMap.Service
yield* Effect.gen(function* () {
const plugins = yield* Plugin.Service
const commands = yield* Command.Service
// The TestClock never advances here, so any timer between boot and the first activation would hang this.
yield* plugins.awaitActivation
expect(yield* commands.get("instance-greet")).toBeDefined()
}).pipe(
Effect.scoped,
Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory.path) }))),
)
expect(source.activations).toBe(1)
}),
)
it.effect("refreshes every 24 hours without adding an immediate reload", () =>
Effect.gen(function* () {
source.activations = 0
const directory = yield* tmpdirScoped()
const locations = yield* LocationServiceMap.Service
yield* Effect.gen(function* () {
const plugins = yield* Plugin.Service
yield* plugins.awaitActivation
yield* TestClock.adjust("23 hours")
expect(source.activations).toBe(1)
yield* TestClock.adjust("1 hour")
yield* advance(() => source.activations === 2)
yield* plugins.awaitActivation
yield* TestClock.adjust("24 hours")
yield* advance(() => source.activations === 3)
yield* plugins.awaitActivation
expect(source.activations).toBe(3)
}).pipe(
Effect.scoped,
Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory.path) }))),
)
}),
)
it.effect("reloads for a trigger published while the initial generation is activating", () =>
Effect.gen(function* () {
source.activations = 0
const sdk = yield* SdkPlugins.Service
const entered = yield* Deferred.make<void>()
const gate = yield* Deferred.make<void>()
yield* sdk.register(
define({
id: "gated",
effect: () => Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(gate))),
}),
)
let late = false
const directory = yield* tmpdirScoped()
const locations = yield* LocationServiceMap.Service
yield* Effect.gen(function* () {
const plugins = yield* Plugin.Service
const commands = yield* Command.Service
yield* Deferred.await(entered)
// Generation 0 is mid-setup: the reload feed must already be subscribed for this update to count.
yield* sdk.register(
define({
id: "late",
effect: (ctx) =>
ctx.command
.transform((editor) => editor.add({ name: "late-greet", execute: () => Effect.void }))
.pipe(Effect.tap(() => Effect.sync(() => (late = true)))),
}),
)
yield* Deferred.succeed(gate, undefined)
yield* advance(() => late)
yield* plugins.awaitActivation
expect(yield* commands.get("late-greet")).toBeDefined()
}).pipe(
Effect.scoped,
Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory.path) }))),
)
expect(source.activations).toBe(2)
}),
)
it.effect("coalesces a burst of reload triggers after the initial generation into one activation", () =>
Effect.gen(function* () {
source.activations = 0
const sdk = yield* SdkPlugins.Service
const directory = yield* tmpdirScoped()
const locations = yield* LocationServiceMap.Service
yield* Effect.gen(function* () {
const plugins = yield* Plugin.Service
const commands = yield* Command.Service
yield* plugins.awaitActivation
expect(source.activations).toBe(1)
yield* sdk.register(greeter("greet-a", "a"))
yield* sdk.register(greeter("greet-b", "b"))
yield* sdk.register(greeter("greet-c", "c"))
yield* advance(() => source.activations > 1)
yield* plugins.awaitActivation
expect(yield* commands.get("greet-a")).toBeDefined()
expect(yield* commands.get("greet-c")).toBeDefined()
}).pipe(
Effect.scoped,
Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory.path) }))),
)
expect(source.activations).toBe(2)
}),
)
})
-4
View File
@@ -1,5 +1 @@
import path from "path"
process.env.OPENCODE_DB = ":memory:"
process.env.OPENCODE_MODELS_PATH = path.join(import.meta.dir, "plugin", "fixtures", "models-dev.json")
process.env.OPENCODE_DISABLE_MODELS_FETCH = "true"
+20
View File
@@ -19,6 +19,26 @@ const referenceLayer = AppNodeBuilder.build(LayerNode.group([Reference.node, Bus
])
describe("Reference", () => {
it.effect("reads the current editor source by name", () =>
Effect.gen(function* () {
const references = yield* Reference.Service
const source = Reference.LocalSource.make({ type: "local", path: AbsolutePath.make("/docs") })
yield* references.transform((editor) => editor.add("docs", source))
yield* references.transform((editor) => {
expect(editor.get("docs")).toBe(editor.list()[0]?.[1])
expect(editor.get("docs")).toEqual(source)
expect(editor.get("missing")).toBeUndefined()
const replacement = Reference.GitSource.make({ type: "git", repository: "owner/repo" })
editor.add("docs", replacement)
expect(editor.get("docs")).toBe(replacement)
editor.remove("docs")
expect(editor.get("docs")).toBeUndefined()
})
expect(yield* references.list()).toEqual([])
}).pipe(Effect.provide(referenceLayer)),
)
it.effect("reads batched references before cache work and update events", () => {
const operations: RepositoryCache.EnsureInput[] = []
const started = Deferred.makeUnsafe<void>()
+30 -15
View File
@@ -2,7 +2,7 @@ import { describe, expect, setDefaultTimeout } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { Clock, Duration, Effect, Layer, Schema } from "effect"
import { Clock, Duration, Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/util/global"
import { Repository } from "@opencode-ai/core/repository"
@@ -35,7 +35,7 @@ describe("RepositoryCache", () => {
expect((yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })).status).toBe("cached")
expect(yield* read(path.join(initial.localPath, "README.md"))).toBe("one\n")
const yesterday = (yield* Clock.currentTimeMillis) - Duration.toMillis(Duration.days(1))
yield* kv.set(`repository-cache:${initial.localPath}`, { attemptedAt: yesterday, refreshedAt: yesterday })
yield* kv.set(`repository-cache:${initial.localPath}`, { attemptedAt: yesterday })
}).pipe(Effect.provide(cacheLayer(fixture.root)))
const results = yield* Effect.all(
@@ -61,7 +61,30 @@ describe("RepositoryCache", () => {
),
)
it.live("throttles failed refresh attempts without marking them successful", () =>
it.live("honors legacy attempt records while allowing forced refreshes", () =>
withRemote((fixture) =>
Effect.gen(function* () {
const cache = yield* RepositoryCache.Service
const kv = yield* KV.Service
const initial = yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })
yield* Effect.promise(() => commit(fixture.source, "two\n", "advance main"))
// Persisted records from older versions include an unused success timestamp.
yield* kv.set(`repository-cache:${initial.localPath}`, {
attemptedAt: yield* Clock.currentTimeMillis,
refreshedAt: 0,
})
expect((yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })).status).toBe("cached")
expect(yield* read(path.join(initial.localPath, "README.md"))).toBe("one\n")
expect((yield* cache.ensure({ reference: fixture.reference, refresh: true })).status).toBe("refreshed")
expect(yield* read(path.join(initial.localPath, "README.md"))).toBe("two\n")
expect((yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })).status).toBe("cached")
}).pipe(Effect.provide(cacheLayer(fixture.root))),
),
)
it.live("throttles failed refresh attempts until the next interval", () =>
withRemote((fixture) =>
Effect.gen(function* () {
const cache = yield* RepositoryCache.Service
@@ -69,31 +92,23 @@ describe("RepositoryCache", () => {
const initial = yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })
const key = `repository-cache:${initial.localPath}`
const yesterday = (yield* Clock.currentTimeMillis) - Duration.toMillis(Duration.days(1))
yield* kv.set(key, { attemptedAt: yesterday, refreshedAt: yesterday })
yield* kv.set(key, { attemptedAt: yesterday })
yield* Effect.promise(() =>
fs.rename(path.join(fixture.root, "origin.git"), path.join(fixture.root, "offline.git")),
)
const error = yield* Effect.flip(cache.ensure({ reference: fixture.reference, refresh: "daily" }))
expect(error).toBeInstanceOf(RepositoryCache.FetchFailedError)
const stored = yield* kv.get(key)
const stamp = Schema.decodeUnknownSync(
Schema.Struct({ attemptedAt: Schema.Number, refreshedAt: Schema.Number }),
)(stored)
expect(stamp.attemptedAt).toBeGreaterThan(yesterday)
expect(stamp.refreshedAt).toBe(yesterday)
expect((yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })).status).toBe("cached")
expect(yield* kv.get(key)).toEqual(stored)
expect(yield* read(path.join(initial.localPath, "README.md"))).toBe("one\n")
yield* Effect.promise(async () => {
await fs.rename(path.join(fixture.root, "offline.git"), path.join(fixture.root, "origin.git"))
await commit(fixture.source, "two\n", "advance main")
})
yield* kv.set(key, { attemptedAt: yesterday, refreshedAt: yesterday })
yield* kv.set(key, { attemptedAt: yesterday })
expect((yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })).status).toBe("refreshed")
expect(yield* read(path.join(initial.localPath, "README.md"))).toBe("two\n")
expect(yield* kv.get(key)).not.toEqual(stored)
}).pipe(Effect.provide(cacheLayer(fixture.root))),
),
)
@@ -157,13 +172,13 @@ describe("RepositoryCache", () => {
withRemote((fixture) =>
Effect.gen(function* () {
const cache = yield* RepositoryCache.Service
const initial = yield* cache.ensure({ reference: fixture.reference })
const initial = yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })
yield* Effect.promise(async () => {
await git(initial.localPath, "config", "remote.origin.url", "https://github.com/other/repo.git")
await fs.writeFile(path.join(initial.localPath, "stale.txt"), "stale")
})
const replaced = yield* cache.ensure({ reference: fixture.reference })
const replaced = yield* cache.ensure({ reference: fixture.reference, refresh: "daily" })
expect(replaced.status).toBe("cloned")
expect(yield* exists(path.join(replaced.localPath, "stale.txt"))).toBe(false)
@@ -13,6 +13,7 @@ import { SessionProjector } from "@opencode-ai/core/session/projector"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { tempGlobalLayer } from "./fixture/global"
import { offlineModels } from "./fixture/models"
import { tmpdirScoped } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
@@ -24,6 +25,7 @@ const it = testEffect(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, Session.node, LocationServiceMap.node]),
[
Global.node.replace(tempGlobalLayer),
offlineModels,
Watcher.node.replace(Watcher.configured({ enabled: false })),
SessionExecution.node.replace(SessionExecution.noopLayer),
],
+6 -1
View File
@@ -35,6 +35,7 @@ import { Workspace } from "@opencode-ai/core/workspace"
import { Expected } from "./lib/session-message"
import { testEffect } from "./lib/effect"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { offlineModels } from "./fixture/models"
import { promptLocationNode } from "./fixture/prompt-location"
import { globalProjectNode } from "./lib/project"
import { tmpdirScoped } from "./fixture/tmpdir"
@@ -61,7 +62,11 @@ const it = testEffect(
const liveIt = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, Project.node, SessionProjector.node, SessionStore.node, Session.node]),
[Bus.node.replace(Bus.configured({ persist: true })), SessionExecution.node.replace(SessionExecution.noopLayer)],
[
Bus.node.replace(Bus.configured({ persist: true })),
SessionExecution.node.replace(SessionExecution.noopLayer),
offlineModels,
],
),
)
const projectIt = testEffect(
+3 -2
View File
@@ -18,6 +18,7 @@ import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionRunner } from "@opencode-ai/core/session/runner/index"
import { SessionStore } from "@opencode-ai/core/session/store"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { offlineModels } from "./fixture/models"
import { tmpdirScoped } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { globalProjectNode } from "./lib/project"
@@ -25,7 +26,7 @@ import { globalProjectNode } from "./lib/project"
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
[Project.node.replace(globalProjectNode), SessionExecution.node.replace(SessionExecution.noopLayer)],
[Project.node.replace(globalProjectNode), SessionExecution.node.replace(SessionExecution.noopLayer), offlineModels],
),
)
const itWithActiveExecution = testEffect(
@@ -47,7 +48,7 @@ const itWithActiveExecution = testEffect(
(ref: Location.Ref) =>
Layer.merge(
LayerNode.compile(Location.boundNode(ref), {
replacements: [Project.node.replace(globalProjectNode)],
replacements: [Project.node.replace(globalProjectNode), offlineModels],
}),
Layer.succeed(SessionRunner.Service, { drain: () => Effect.never }),
) as unknown as Layer.Layer<LocationServices>,
@@ -17,6 +17,7 @@ import { SessionEnvironment } from "@opencode-ai/core/session/environment"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { testEffect } from "./lib/effect"
import { globalProjectNode } from "./lib/project"
import { offlineModels } from "./fixture/models"
import { tmpdirScoped } from "./fixture/tmpdir"
const closed: Session.ID[] = []
@@ -50,6 +51,7 @@ const it = testEffect(
Project.node.replace(globalProjectNode),
SessionExecution.node.replace(SessionExecution.noopLayer),
SessionModelTransport.node.replace(transport),
offlineModels,
],
),
)
@@ -23,6 +23,7 @@ import { Money } from "@opencode-ai/schema/money"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { tempGlobalLayer } from "./fixture/global"
import { offlineModels } from "./fixture/models"
import { tmpdirScoped } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
@@ -33,6 +34,7 @@ const it = testEffect(
Bus.node.replace(Bus.configured({ persist: true })),
Global.node.replace(tempGlobalLayer),
SessionExecution.node.replace(SessionExecution.noopLayer),
offlineModels,
],
),
)
+2
View File
@@ -14,6 +14,7 @@ import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator
import { Shell } from "@opencode-ai/core/shell"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { location } from "./fixture/location"
import { offlineModels } from "./fixture/models"
import { tmpdirScoped } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
@@ -59,6 +60,7 @@ const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Bus.node, Session.node, SessionExecution.node, LocationServiceMap.node]), [
Bus.node.replace(Bus.configured({ persist: true })),
SessionExecution.node.replace(executionLayer.pipe(Layer.provide(controlLayer))),
offlineModels,
]).pipe(Layer.provideMerge(controlLayer)),
)
+1 -1
View File
@@ -111,7 +111,7 @@ for (const fixture of [
executeTool: () =>
Effect.sync(() => {
executions++
return { content: "Completed tool" }
return { content: [{ type: "text", text: "Completed tool" }] }
}),
},
retry: (_cause, _error, retry) =>
+19
View File
@@ -20,6 +20,25 @@ const info = (id: string, description: string) =>
})
describe("Skill", () => {
it.effect("reads the current editor entry by ID", () =>
Effect.gen(function* () {
const skill = yield* Skill.Service
yield* skill.transform((editor) => editor.add(info("review", "Initial")))
yield* skill.transform((editor) => {
expect(editor.get("review")).toBe(editor.list()[0])
expect(editor.get("missing")).toBeUndefined()
editor.update("review", (value) => {
value.description = "Updated"
})
expect(editor.get("review")?.description).toBe("Updated")
editor.remove("review")
expect(editor.get("review")).toBeUndefined()
})
expect(yield* skill.list()).toEqual([])
}),
)
it.effect("registers values with last-write-wins precedence", () =>
Effect.gen(function* () {
const skill = yield* Skill.Service
+9 -7
View File
@@ -4,6 +4,7 @@ import { Effect } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { ToolOutput } from "@opencode-ai/core/tool-output"
import type { Tool } from "@opencode-ai/core/tool"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Identifier } from "@opencode-ai/core/id/id"
@@ -36,7 +37,7 @@ describe("ToolOutput", () => {
(service, fs) =>
Effect.gen(function* () {
const output = { items: [1, 2, 3] }
const result = yield* service.truncate({ output, content: "one\ntwo\nthree" })
const result = yield* service.truncate({ output, content: [{ type: "text", text: "one\ntwo\nthree" }] })
expect(result.output).toBe(output)
expect(result.metadata).toMatchObject({ truncated: true })
const outputPath = result.metadata?.outputPath
@@ -56,7 +57,7 @@ describe("ToolOutput", () => {
withStore(
(output) =>
Effect.gen(function* () {
const result = yield* output.truncate({ content: "one\ntwo" })
const result = yield* output.truncate({ content: [{ type: "text", text: "one\ntwo" }] })
expect(result.content).toEqual([
{ type: "text", text: "one" },
{
@@ -91,8 +92,9 @@ describe("ToolOutput", () => {
it.live("skips results that report a truncation state", () =>
withStore((output) =>
Effect.gen(function* () {
const truncated = { content: "one\ntwo", metadata: { truncated: true, source: "tool" } }
const retained = { content: "one\ntwo", metadata: { truncated: false, source: "tool" } }
const content: Tool.NormalizedResult["content"] = [{ type: "text", text: "one\ntwo" }]
const truncated = { content, metadata: { truncated: true, source: "tool" } }
const retained = { content, metadata: { truncated: false, source: "tool" } }
expect(yield* output.truncate(truncated)).toBe(truncated)
expect(yield* output.truncate(retained)).toBe(retained)
}),
@@ -112,8 +114,8 @@ describe("ToolOutput", () => {
withStore(
(output) =>
Effect.gen(function* () {
expect(yield* output.truncate({ content: "one\ntwo\n" })).toEqual({
content: "one\ntwo\n",
expect(yield* output.truncate({ content: [{ type: "text", text: "one\ntwo\n" }] })).toEqual({
content: [{ type: "text", text: "one\ntwo\n" }],
metadata: { truncated: false },
})
}),
@@ -125,7 +127,7 @@ describe("ToolOutput", () => {
withStore(
(output) =>
Effect.gen(function* () {
const result = yield* output.truncate({ content: "one\n" })
const result = yield* output.truncate({ content: [{ type: "text", text: "one\n" }] })
expect(result.content).toEqual([
{ type: "text", text: "one" },
{ type: "text", text: expect.stringMatching(/^\.\.\. 1 byte truncated; full content saved to /) },
+35 -1
View File
@@ -3,11 +3,13 @@ import { Agent } from "@opencode-ai/core/agent"
import type { Permission } from "@opencode-ai/core/permission"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Image } from "@opencode-ai/core/image"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { Session } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { State } from "@opencode-ai/core/state"
import { Tool } from "@opencode-ai/core/tool"
import type { Info } from "@opencode-ai/schema/tool"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { codeModeListings, executeTool, toolDefinitions } from "./lib/tool"
import { Deferred, Effect, Exit, Fiber, Layer, Logger, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
import { z } from "zod"
@@ -35,7 +37,9 @@ const imageStore = Layer.mock(Image.Service, {
})
},
})
const registryLayer = AppNodeBuilder.build(Tool.node, [Image.node.replace(imageStore)])
const registryLayer = AppNodeBuilder.build(LayerNode.group([Tool.node, PluginHooks.node]), [
Image.node.replace(imageStore),
])
const it = testEffect(registryLayer)
const identity = {
agent: Agent.ID.make("build"),
@@ -843,6 +847,36 @@ describe("Tool", () => {
])
}),
)
;[
{ name: "string", content: "hooked", text: "hooked" },
{ name: "empty string", content: "", text: "" },
{ name: "missing content", content: undefined, text: '{"text":"hooked"}' },
{ name: "empty content array", content: [], text: '{"text":"hooked"}' },
].forEach((input) => {
it.effect(`normalizes ${input.name} after the final tool hook`, () =>
Effect.gen(function* () {
const service = yield* Tool.Service
const hooks = yield* PluginHooks.Service
yield* transform(service, { echo: make() }, { codemode: false })
yield* hooks.register("tool", "execute.after", (event) =>
Effect.sync(() => {
if (event.status !== "completed") return
event.result = {
output: { text: "hooked" },
content: input.content,
metadata: { source: "hook" },
}
}),
)
const snapshot = yield* service.snapshot()
expect(yield* snapshot.execute(call("echo"))).toEqual({
output: { text: "hooked" },
content: [{ type: "text", text: input.text }],
metadata: { source: "hook" },
})
}),
)
})
it.effect("normalizes image tool output once and drops unresizable images", () =>
Effect.gen(function* () {
+3
View File
@@ -40,6 +40,7 @@ import { ToolOutput } from "@opencode-ai/core/tool-output"
import { Tool } from "@opencode-ai/core/tool"
import { tmpdir, tmpdirScoped } from "./fixture/tmpdir"
import { tempGlobalLayer } from "./fixture/global"
import { offlineModels } from "./fixture/models"
import { testEffect } from "./lib/effect"
import { permissionLayer } from "./lib/permission"
import { Expected } from "./lib/session-message"
@@ -155,6 +156,7 @@ const replacements = [
SessionExecution.node.replace(executionNode),
Permission.node.replace(permission),
Global.node.replace(tempGlobalLayer),
offlineModels,
] satisfies LayerNode.Replacements
const productionIt = testEffect(AppNodeBuilder.build(nodes, replacements))
const it = testEffect(
@@ -165,6 +167,7 @@ const permissionIt = testEffect(
SessionExecution.node.replace(executionNode),
Global.node.replace(tempGlobalLayer),
PluginSupervisor.node.replace(shellPluginSupervisor),
offlineModels,
]),
)
+3
View File
@@ -36,6 +36,7 @@ import { SubagentTool } from "@opencode-ai/core/tool/plugin/subagent"
import { Tool } from "@opencode-ai/core/tool"
import { tmpdir } from "./fixture/tmpdir"
import { tempGlobalLayer } from "./fixture/global"
import { offlineModels } from "./fixture/models"
import { testEffect } from "./lib/effect"
import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
@@ -120,6 +121,7 @@ const nodes = LayerNode.group([
const replacements = [
SessionExecution.node.replace(executionNode),
Global.node.replace(tempGlobalLayer),
offlineModels,
] satisfies LayerNode.Replacements
const productionIt = testEffect(AppNodeBuilder.build(nodes, replacements))
const it = testEffect(
@@ -128,6 +130,7 @@ const it = testEffect(
const completionIt = testEffect(
AppNodeBuilder.build(LayerNode.group([nodes, SessionRestart.node, KV.node]), [
Global.node.replace(tempGlobalLayer),
offlineModels,
PluginSupervisor.node.replace(subagentPluginSupervisor),
LayerNodePlatform.llmClient.replace(TestLLM.testLayer({ fallback: TestLLM.text(childText, "completion") })),
SessionRunnerModel.node.replace(
+6 -39
View File
@@ -10,8 +10,6 @@ import type {
} from "@opencode-ai/app/wsl/types"
import { Effect } from "effect"
import { nativeT } from "../native/translations"
import { WSL_SERVERS_KEY } from "../storage/keys"
import { getStore } from "../storage/store"
import {
installWslRuntimeElevated,
listInstalledWslDistros,
@@ -38,8 +36,8 @@ type WslServersControllerOptions = {
spawnSidecar: SpawnSidecar
installCli: (distro: string, cli: WslCliBuild) => Promise<void>
installDistro: (distro: string) => Promise<void>
readServers?: () => WslServerConfig[]
writeServers?: (servers: WslServerConfig[]) => void
readServers: () => WslServerConfig[]
writeServers: (servers: WslServerConfig[]) => void
probeDistro?: typeof probeWslDistro
resolveCli?: typeof resolveWslCli
readCliVersion?: typeof readWslCliVersion
@@ -60,8 +58,6 @@ export const createWslServersController = Effect.fn("WslServers.make")(function*
const sidecars = new Map<string, RunningSidecar>()
const starts = new Map<string, symbol>()
let closed = false
const readServers = options.readServers ?? readPersistedServers
const writeServers = options.writeServers ?? writePersistedServers
const probeDistro = options.probeDistro ?? probeWslDistro
const emit = () => {
@@ -79,7 +75,7 @@ export const createWslServersController = Effect.fn("WslServers.make")(function*
}
const refreshFromStore = () => {
const persisted = readServers()
const persisted = options.readServers()
const items: WslServerItem[] = persisted.map((config) => {
const existing = state.servers.find((item) => item.config.id === config.id)
return {
@@ -291,7 +287,7 @@ export const createWslServersController = Effect.fn("WslServers.make")(function*
id,
distro,
}
writeServers([...readServers(), config])
options.writeServers([...options.readServers(), config])
setState({
servers: [...state.servers, { config, runtime: { kind: "starting" } }],
})
@@ -302,8 +298,8 @@ export const createWslServersController = Effect.fn("WslServers.make")(function*
async removeServer(id: string) {
const distro = state.servers.find((item) => item.config.id === id)?.config.distro
await stopServer(id)
const remaining = readServers().filter((item) => item.id !== id)
writeServers(remaining)
const remaining = options.readServers().filter((item) => item.id !== id)
options.writeServers(remaining)
setState({
servers: state.servers.filter((item) => item.config.id !== id),
...(distro ? removeDistroState(state, distro) : {}),
@@ -334,35 +330,6 @@ function initialState(): WslServersState {
}
}
function readPersistedServers(): WslServerConfig[] {
const store = getStore()
const existing = store.get(WSL_SERVERS_KEY)
if (existing && typeof existing === "object") {
const record = existing as { servers?: unknown }
const list = Array.isArray(record.servers) ? record.servers : []
return list.flatMap(normalizePersistedServer)
}
return []
}
function writePersistedServers(servers: WslServerConfig[]) {
getStore().set(WSL_SERVERS_KEY, { servers })
}
function normalizePersistedServer(value: unknown): WslServerConfig[] {
if (!value || typeof value !== "object") return []
const record = value as Record<string, unknown>
const distro = typeof record.distro === "string" && record.distro.length > 0 ? record.distro : null
if (!distro) return []
const id = typeof record.id === "string" && record.id.length > 0 ? record.id : wslServerIdForDistro(distro)
return [
{
id,
distro,
},
]
}
function cliCheck(
distro: string,
resolvedPath: string | null,
+18 -1
View File
@@ -3,6 +3,7 @@ export * as Wsl from "./start"
import { Context, Effect, Exit, FileSystem, Layer, Path } from "effect"
import { Shutdown } from "../lifecycle/shutdown"
import { DesktopCli } from "../service/desktop-cli"
import { WSL_SERVERS_KEY } from "../storage/keys"
import { WslIpc } from "./ipc"
export interface Interface extends WslIpc.Interface {
@@ -27,7 +28,8 @@ export const layer = Layer.effect(
const makeWsl = Effect.fn("Wsl.make")(function* (cli: DesktopCli.Resolved) {
if (process.platform !== "win32") return { ...WslIpc.create(), stop: Effect.void }
const { createWslServersController } = yield* Effect.promise(() => import("./servers"))
const { createWslServersController, wslServerIdForDistro } = yield* Effect.promise(() => import("./servers"))
const { getStore } = yield* Effect.promise(() => import("../storage/store"))
const { spawnWslSidecar } = yield* Effect.promise(() => import("./sidecar"))
const { installWslCli, installWslDistro } = yield* Effect.promise(() => import("./runtime"))
const context = yield* Effect.context<FileSystem.FileSystem | Path.Path>()
@@ -36,6 +38,21 @@ const makeWsl = Effect.fn("Wsl.make")(function* (cli: DesktopCli.Resolved) {
const local = cli.wslBuild
const controller = yield* createWslServersController({
cli: { version: cli.version },
readServers: () => {
const existing = getStore().get(WSL_SERVERS_KEY)
if (!existing || typeof existing !== "object") return []
const record = existing as { servers?: unknown }
const list = Array.isArray(record.servers) ? record.servers : []
return list.flatMap((value: unknown) => {
if (!value || typeof value !== "object") return []
const record = value as Record<string, unknown>
const distro = typeof record.distro === "string" && record.distro.length > 0 ? record.distro : null
if (!distro) return []
const id = typeof record.id === "string" && record.id.length > 0 ? record.id : wslServerIdForDistro(distro)
return [{ id, distro }]
})
},
writeServers: (servers) => getStore().set(WSL_SERVERS_KEY, { servers }),
installDistro: (distro) => run(installWslDistro(distro)),
installCli: local
? async (distro) => {
+4
View File
@@ -5,12 +5,14 @@
"type": "module",
"license": "MIT",
"scripts": {
"test": "bun test --only-failures",
"typecheck": "tsgo --noEmit",
"build": "tsc -p tsconfig.build.json"
},
"exports": {
".": "./src/promise/index.ts",
"./effect": "./src/effect/index.ts",
"./host": "./src/host.ts",
"./tui": "./src/tui/index.ts",
"./*": "./src/*.ts"
},
@@ -23,6 +25,7 @@
"@opencode-ai/client": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
"zod": "catalog:"
@@ -53,6 +56,7 @@
"@opentui/solid": "catalog:",
"@tsconfig/bun": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"solid-js": "catalog:",
"typescript": "catalog:",
+1
View File
@@ -7,6 +7,7 @@ export interface ReferenceEditor {
add(name: string, source: ReferenceLocalSource | ReferenceGitSource): void
remove(name: string): void
list(): readonly (readonly [string, ReferenceLocalSource | ReferenceGitSource])[]
get(name: string): ReferenceLocalSource | ReferenceGitSource | undefined
}
export interface ReferenceDomain extends ReferenceApi<unknown> {
+1
View File
@@ -5,6 +5,7 @@ import type { Transform } from "./registration.js"
export interface SkillEditor {
list(): readonly Types.DeepMutable<Skill.Info>[]
get(id: string): Types.DeepMutable<Skill.Info> | undefined
add(skill: Skill.Info): void
update(id: string, update: (skill: Types.DeepMutable<Skill.Info>) => void): void
remove(id: string): void
+48
View File
@@ -0,0 +1,48 @@
export * as Host from "./host.js"
import path from "node:path"
import { importModule, resolveModule } from "@opencode-ai/util/runtime-import"
export interface Target {
readonly directory: string
readonly name?: string
}
export interface Entrypoints {
readonly server?: string
readonly tui?: string
readonly rpc?: string
}
export function resolve(target: Target): Entrypoints {
const entry = (subpaths: readonly string[]) => {
for (const subpath of subpaths) {
const specifier = target.name
? [target.name, subpath].filter(Boolean).join("/")
: path.resolve(target.directory, subpath || "index")
try {
return resolveModule(specifier, target.directory)
} catch (error) {
if (
!(error instanceof Error) ||
!("code" in error) ||
![
"ENOENT",
"ENOTDIR",
"MODULE_NOT_FOUND",
"ERR_MODULE_NOT_FOUND",
"ERR_PACKAGE_PATH_NOT_EXPORTED",
"ERR_UNSUPPORTED_DIR_IMPORT",
].includes(String(error.code))
)
throw error
}
}
return undefined
}
return { server: entry(["server", ""]), tui: entry(["tui"]), rpc: entry(["rpc"]) }
}
export function load(entrypoint: string): Promise<unknown> {
return importModule(entrypoint)
}

Some files were not shown because too many files have changed in this diff Show More