Compare commits

..

3 Commits

Author SHA1 Message Date
Aiden Cline 2f6a907a1a fix(ai): reconcile terminal response items 2026-07-25 14:45:15 -05:00
Aiden Cline d771c22e94 fix(ai): deduplicate terminal response items 2026-07-25 14:39:31 -05:00
Aiden Cline 02e8f398b8 fix(ai): reconcile responses snapshots 2026-07-25 14:37:34 -05:00
283 changed files with 84785 additions and 47527 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/ai": patch
---
Report OpenAI prompt cache write tokens in normalized usage.
+7
View File
@@ -0,0 +1,7 @@
---
"@opencode-ai/client": patch
"@opencode-ai/protocol": patch
"@opencode-ai/cli": patch
---
Expose background-service lifecycle status, preserve one process-held owner through startup and failure, reconnect TUIs without activating replacement, and stop exact service instances gracefully.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/cli": patch
---
Expose a TUI plugin slot at the top of the session view.
+8
View File
@@ -0,0 +1,8 @@
---
"@opencode-ai/plugin": minor
"@opencode-ai/sdk": minor
"@opencode-ai/client": minor
"@opencode-ai/protocol": minor
---
Replace the V2 tool result model with one canonical representation per fact. Tools lose `structured`, projection callbacks, the `Structured` generic, and the exported `Tool.settle` interpreter; tool responses carry schema-validated `output`, model-visible `content`, and optional compact JSON `metadata`. Code Mode receives the validated encoded output. Durable tool success stores non-empty model content plus optional metadata; failure stores one error plus the final bounded partial snapshot. Progress carries metadata only, while `execute.after` hooks receive the canonical terminal outcome and managed `outputPaths`. A one-time migration rewrites existing projected tool rows and moves provider-hosted result payloads into provider-owned result state.
+7
View File
@@ -0,0 +1,7 @@
---
"@opencode-ai/client": patch
"@opencode-ai/plugin": patch
"@opencode-ai/protocol": patch
---
Expose transient, read-only session generation through the HTTP API, generated clients, and V2 plugin session context.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/cli": patch
---
Expose a TUI plugin slot above the session composer.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/ai": patch
---
Improve Anthropic and Bedrock prompt reuse with layered cache breakpoints that roll through long tool loops.
-37
View File
@@ -1,37 +0,0 @@
name: deploy-www
on:
push:
branches:
- dev
- v2
workflow_dispatch:
concurrency:
group: deploy-www-${{ github.ref_name }}
cancel-in-progress: false
permissions:
contents: read
jobs:
deploy:
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'dev' || github.ref_name == 'v2')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ./.github/actions/setup-bun
- name: Build
working-directory: packages/www
run: bun run build
env:
BLUME_ENV: ${{ github.ref_name == 'v2' && 'production' || 'dev' }}
CLOUDFLARE_ENV: ${{ github.ref_name == 'v2' && 'production' || 'dev' }}
- name: Deploy
working-directory: packages/www
run: bun run deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+1 -1
View File
@@ -99,7 +99,7 @@ jobs:
- name: Check generated documentation
if: runner.os == 'Linux'
working-directory: packages/www
working-directory: packages/docs
run: bun run check:generated
e2e:
+1
View File
@@ -1,3 +1,4 @@
- To regenerate the legacy JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly.
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server.
- Do not modify `packages/opencode` unless the user explicitly asks for V1 work. `packages/opencode` is the V1 implementation and is present for reference only. New implementation changes should land in the V2 package set: `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
+1639 -1609
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -2,7 +2,7 @@
exact = true
# Only install newly resolved package versions published at least 3 days ago.
minimumReleaseAge = 259200
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opencode-ai/sdk", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"]
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"]
[test]
root = "./do-not-run-tests-from-root"
+1 -1
View File
@@ -15,6 +15,6 @@
"@actions/github": "6.0.1",
"@octokit/graphql": "9.0.1",
"@octokit/rest": "catalog:",
"@opencode-ai/sdk": "1.18.5"
"@opencode-ai/sdk": "workspace:*"
}
}
+2 -1
View File
@@ -33,6 +33,7 @@
"packages/*",
"packages/console/*",
"packages/stats/*",
"packages/sdk/js",
"packages/slack"
],
"catalog": {
@@ -123,7 +124,7 @@
"@aws-sdk/client-s3": "3.933.0",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "1.18.5",
"@opencode-ai/sdk": "workspace:*",
"heap-snapshot-toolkit": "1.1.3",
"typescript": "catalog:"
},
+362 -42
View File
@@ -193,6 +193,14 @@ const OpenResponsesUsage = Schema.Struct({
})
type OpenResponsesUsage = Schema.Schema.Type<typeof OpenResponsesUsage>
const StreamContent = Schema.StructWithRest(
Schema.Struct({
type: Schema.String,
text: Schema.optional(Schema.String),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
export const StreamItem = Schema.StructWithRest(
Schema.Struct({
type: Schema.String,
@@ -201,6 +209,8 @@ export const StreamItem = Schema.StructWithRest(
name: Schema.optional(Schema.String),
arguments: Schema.optional(Schema.String),
encrypted_content: optionalNull(Schema.String),
content: optionalArray(StreamContent),
summary: optionalArray(StreamContent),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
@@ -221,7 +231,10 @@ export const Event = Schema.StructWithRest(
type: Schema.String,
delta: Schema.optional(Schema.String),
text: Schema.optional(Schema.String),
arguments: Schema.optional(Schema.String),
item_id: Schema.optional(Schema.String),
output_index: Schema.optional(Schema.Number),
content_index: Schema.optional(Schema.Number),
summary_index: Schema.optional(Schema.Number),
item: Schema.optional(StreamItem),
response: Schema.optional(
@@ -232,6 +245,7 @@ export const Event = Schema.StructWithRest(
incomplete_details: optionalNull(Schema.Struct({ reason: Schema.optional(Schema.String) })),
usage: optionalNull(OpenResponsesUsage),
error: optionalNull(OpenResponsesErrorPayload),
output: optionalArray(StreamItem),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
),
@@ -264,10 +278,16 @@ export interface ParserState {
readonly providerMetadataKey: string
readonly tools: ToolStream.State<string>
readonly hasFunctionCall: boolean
readonly completedItems: ReadonlySet<string>
readonly functionArguments: Readonly<Record<string, string>>
readonly outputIndexes: Readonly<Record<string, number>>
readonly outputSequence: ReadonlyArray<string>
readonly lifecycle: Lifecycle.State
readonly messageItems: ReadonlySet<string>
readonly messagePhase: (value: unknown) => MessagePhase | null | undefined
readonly messagePhases: Readonly<Record<string, MessagePhase | null>>
readonly outputText: Readonly<Record<string, string>>
readonly reasoningText: Readonly<Record<string, string>>
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
readonly store: boolean | undefined
}
@@ -642,24 +662,60 @@ export const terminal = (event: Event) => TERMINAL_TYPES.has(event.type)
const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepResult => {
if (!event.delta) return [state, NO_EVENTS]
const key =
event.content_index === undefined
? id
: event.content_index === 0 && state.outputText[`${id}:0`] === undefined && state.outputText[id] !== undefined
? id
: `${id}:${event.content_index}`
const events: LLMEvent[] = []
const phase = state.messagePhases[id]
const metadata = phase === undefined ? undefined : providerMetadata(state, { phase })
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata)
return [
{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta) },
{
...state,
lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta),
outputText: { ...state.outputText, [key]: `${state.outputText[key] ?? ""}${event.delta}` },
},
events,
]
}
const onOutputTextDone = (state: ParserState, event: Event, id: string): StepResult => {
if (state.messageItems.has(id)) {
if (state.lifecycle.text.has(id) || event.text === undefined) return [state, NO_EVENTS]
return onOutputTextDelta(state, { ...event, delta: event.text }, id)
}
const authoritativeSuffix = Effect.fn("OpenResponses.authoritativeSuffix")(function* (
state: ParserState,
kind: string,
id: string,
current: string,
value: string,
) {
if (value.startsWith(current)) return value.slice(current.length)
return yield* ProviderShared.eventError(
state.id,
`${kind} ${id} completed with content that conflicts with its streamed deltas`,
value,
)
})
const onOutputTextDone = Effect.fn("OpenResponses.onOutputTextDone")(function* (
state: ParserState,
event: Event,
id: string,
) {
const key =
event.content_index === undefined
? id
: event.content_index === 0 && state.outputText[`${id}:0`] === undefined && state.outputText[id] !== undefined
? id
: `${id}:${event.content_index}`
const suffix =
event.text === undefined ? "" : yield* authoritativeSuffix(state, "output text", key, state.outputText[key] ?? "", event.text)
const [reconciled, deltaEvents] = onOutputTextDelta(state, { ...event, delta: suffix }, id)
if (reconciled.messageItems.has(id)) return [reconciled, deltaEvents] satisfies StepResult
const events: LLMEvent[] = []
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, id) }, events]
}
const lifecycle = Lifecycle.textEnd(reconciled.lifecycle, events, id)
return [{ ...reconciled, lifecycle }, [...deltaEvents, ...events]] satisfies StepResult
})
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
if (!event.delta) return [state, NO_EVENTS]
@@ -670,12 +726,39 @@ export const onReasoningDelta = (state: ParserState, event: Event, itemID: strin
{
...state,
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, id, event.delta),
reasoningText: { ...state.reasoningText, [id]: `${state.reasoningText[id] ?? ""}${event.delta}` },
},
events,
]
}
export const onReasoningDone = (state: ParserState, _event: Event): StepResult => [state, NO_EVENTS]
export const onReasoningDone = Effect.fn("OpenResponses.onReasoningDone")(function* (
state: ParserState,
event: Event,
) {
if (!event.item_id || event.text === undefined) return [state, NO_EVENTS] satisfies StepResult
const id =
event.summary_index !== undefined || state.reasoningItems[event.item_id]
? `${event.item_id}:${event.summary_index ?? 0}`
: event.item_id
const suffix = yield* authoritativeSuffix(state, "reasoning", id, state.reasoningText[id] ?? "", event.text)
const [reconciled, events] = onReasoningDelta(state, { ...event, delta: suffix }, event.item_id)
const item = reconciled.reasoningItems[event.item_id]
if (!suffix || !item || event.summary_index === undefined) return [reconciled, events] satisfies StepResult
return [
{
...reconciled,
reasoningItems: {
...reconciled.reasoningItems,
[event.item_id]: {
...item,
summaryParts: { ...item.summaryParts, [event.summary_index]: "active" },
},
},
},
events,
] satisfies StepResult
})
const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }) =>
providerMetadata(state, { itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
@@ -858,38 +941,78 @@ const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgu
return [{ ...state, lifecycle, tools: result.tools }, events] satisfies StepResult
})
const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (state: ParserState, event: Event) {
export const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
state: ParserState,
event: Event,
) {
const item = event.item
if (!item) return [state, NO_EVENTS] satisfies StepResult
if (item.type === "message" && item.id) {
const itemID = item.id
const itemPhase = state.messagePhase(item.phase)
const phase = itemPhase === undefined ? state.messagePhases[item.id] : itemPhase
const messageItems = new Set([...state.messageItems, item.id])
const messagePhases = phase === undefined ? state.messagePhases : { ...state.messagePhases, [item.id]: phase }
const reconciledEvents: LLMEvent[] = []
const reconciled = yield* (item.content ?? [])
.map((part, contentIndex) => ({ part, contentIndex }))
.filter((entry) => entry.part.type === "output_text" && entry.part.text !== undefined)
.reduce<Effect.Effect<ParserState, LLMError>>(
(effect, entry) =>
effect.pipe(
Effect.flatMap(
Effect.fnUntraced(function* (current) {
const [next, nextEvents] = yield* onOutputTextDone(
current,
{ ...event, content_index: entry.contentIndex, text: entry.part.text ?? "" },
itemID,
)
reconciledEvents.push(...nextEvents)
return next
}),
),
),
Effect.succeed({ ...state, messageItems, messagePhases }),
)
const events: LLMEvent[] = []
const messageItems = new Set(state.messageItems)
messageItems.delete(item.id)
const { [item.id]: _phase, ...messagePhases } = state.messagePhases
const { [item.id]: _phase, ...remainingPhases } = reconciled.messagePhases
const metadata = phase === undefined ? undefined : providerMetadata(state, { phase })
const lifecycle = Lifecycle.textEnd(reconciled.lifecycle, events, item.id, metadata)
if (
!events.length &&
Object.keys(state.outputText).some((id) => id === item.id || id.startsWith(`${item.id}:`)) &&
metadata
)
events.push(LLMEvent.textEnd({ id: item.id, providerMetadata: metadata }))
return [
{
...state,
lifecycle: Lifecycle.textEnd(
state.lifecycle,
events,
item.id,
phase === undefined ? undefined : providerMetadata(state, { phase }),
),
...reconciled,
lifecycle,
messageItems,
messagePhases,
messagePhases: remainingPhases,
completedItems: new Set([...reconciled.completedItems, item.id]),
},
events,
[...reconciledEvents, ...events],
] satisfies StepResult
}
if (item.type === "function_call") {
if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
if (state.completedItems.has(item.id)) {
if (item.arguments === undefined || item.arguments === state.functionArguments[item.id])
return [state, NO_EVENTS] satisfies StepResult
return yield* ProviderShared.eventError(
state.id,
`function call arguments ${item.id} completed with content that conflicts with its previous snapshot`,
item.arguments,
)
}
const tools = state.tools[item.id]
? state.tools
: ToolStream.start(state.tools, item.id, { id: item.call_id, name: item.name })
const authoritativeArguments = item.arguments ?? tools[item.id]?.input
const result =
item.arguments === undefined
? yield* ToolStream.finish(state.id, tools, item.id)
@@ -905,6 +1028,11 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
hasFunctionCall:
resultEvents.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
state.hasFunctionCall,
completedItems: new Set([...state.completedItems, item.id]),
functionArguments: {
...state.functionArguments,
...(authoritativeArguments === undefined ? {} : { [item.id]: authoritativeArguments }),
},
tools: result.tools,
},
events,
@@ -914,25 +1042,147 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (isReasoningItem(item)) {
const events: LLMEvent[] = []
const metadata = reasoningMetadata(state, item)
const reasoningItem = state.reasoningItems[item.id]
const summaries = item.summary?.filter((part) => part.type === "summary_text" && part.text !== undefined) ?? []
if (state.completedItems.has(item.id)) {
const reconciled = yield* summaries
.map((part, index) => ({ part, index }))
.reduce<Effect.Effect<ParserState, LLMError>>(
(effect, entry) =>
effect.pipe(
Effect.flatMap(
Effect.fnUntraced(function* (current) {
const [next, nextEvents] = yield* onReasoningDone(current, {
...event,
item_id: item.id,
summary_index: entry.index,
text: entry.part.text,
})
events.push(...nextEvents)
const endEvents: LLMEvent[] = []
const lifecycle = Lifecycle.reasoningEnd(
next.lifecycle,
endEvents,
`${item.id}:${entry.index}`,
metadata,
)
events.push(
...(endEvents.length
? endEvents
: [LLMEvent.reasoningEnd({ id: `${item.id}:${entry.index}`, providerMetadata: metadata })]),
)
return { ...next, lifecycle }
}),
),
),
Effect.succeed(state),
)
if (!summaries.length) {
const id = Object.keys(state.reasoningText)
.filter((id) => id === item.id || id.startsWith(`${item.id}:`))
.at(-1) ?? `${item.id}:0`
events.push(LLMEvent.reasoningEnd({ id, providerMetadata: metadata }))
}
return [reconciled, events] satisfies StepResult
}
if (summaries.length === 1 && !state.reasoningItems[item.id] && state.reasoningText[item.id] !== undefined) {
const [reconciled, reconciledEvents] = yield* onReasoningDone(state, {
...event,
item_id: item.id,
text: summaries[0]?.text,
})
events.push(...reconciledEvents)
return [
{
...reconciled,
lifecycle: Lifecycle.reasoningEnd(reconciled.lifecycle, events, item.id, metadata),
completedItems: new Set([...reconciled.completedItems, item.id]),
},
events,
] satisfies StepResult
}
const needsSummaryReconciliation = summaries.some(
(part, index) => part.text !== state.reasoningText[`${item.id}:${index}`],
)
const seeded =
needsSummaryReconciliation && !state.reasoningItems[item.id]
? onOutputItemAdded(state, { ...event, item })
: ([state, NO_EVENTS] satisfies StepResult)
events.push(...seeded[1])
const reconciled = yield* (needsSummaryReconciliation ? summaries : [])
.map((part, index) => ({ part, index }))
.reduce<Effect.Effect<ParserState, LLMError>>(
(effect, entry) =>
effect.pipe(
Effect.flatMap(
Effect.fnUntraced(function* (current) {
const added = current.reasoningItems[item.id]?.summaryParts[entry.index]
? ([current, NO_EVENTS] satisfies StepResult)
: onReasoningSummaryPartAdded(current, {
...event,
item_id: item.id,
summary_index: entry.index,
})
events.push(...added[1])
const [next, nextEvents] = yield* onReasoningDone(added[0], {
...event,
item_id: item.id,
summary_index: entry.index,
text: entry.part.text,
})
events.push(...nextEvents)
const [done, doneEvents] = onReasoningSummaryPartDone(next, {
...event,
item_id: item.id,
summary_index: entry.index,
})
events.push(...doneEvents)
return done
}),
),
),
Effect.succeed(seeded[0]),
)
const reasoningItem = reconciled.reasoningItems[item.id]
if (reasoningItem) {
const lifecycle = Object.entries(reasoningItem.summaryParts)
.filter((entry) => entry[1] === "active" || entry[1] === "can-conclude")
.reduce(
(lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${entry[0]}`, metadata),
state.lifecycle,
reconciled.lifecycle,
)
const { [item.id]: _removed, ...reasoningItems } = state.reasoningItems
return [{ ...state, lifecycle, reasoningItems }, events] satisfies StepResult
const { [item.id]: _removed, ...reasoningItems } = reconciled.reasoningItems
return [
{
...reconciled,
lifecycle,
reasoningItems,
completedItems: new Set([...reconciled.completedItems, item.id]),
},
events,
] satisfies StepResult
}
if (!state.lifecycle.reasoning.has(item.id)) {
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
if (summaries.length) {
events.push(LLMEvent.reasoningEnd({ id: `${item.id}:${summaries.length - 1}`, providerMetadata: metadata }))
return [
{ ...reconciled, completedItems: new Set([...reconciled.completedItems, item.id]) },
events,
] satisfies StepResult
}
if (!reconciled.lifecycle.reasoning.has(item.id)) {
const lifecycle = Lifecycle.stepStart(reconciled.lifecycle, events)
events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata: metadata }))
events.push(LLMEvent.reasoningEnd({ id: item.id, providerMetadata: metadata }))
return [{ ...state, lifecycle }, events] satisfies StepResult
return [
{ ...reconciled, lifecycle, completedItems: new Set([...reconciled.completedItems, item.id]) },
events,
] satisfies StepResult
}
return [
{ ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata) },
{
...reconciled,
lifecycle: Lifecycle.reasoningEnd(reconciled.lifecycle, events, item.id, metadata),
completedItems: new Set([...reconciled.completedItems, item.id]),
},
events,
] satisfies StepResult
}
@@ -940,11 +1190,47 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
return [state, NO_EVENTS] satisfies StepResult
})
const onResponseFinish = (state: ParserState, event: Event): StepResult => {
export const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
state: ParserState,
event: Event,
onItemDone: (state: ParserState, event: Event) => Effect.Effect<StepResult, LLMError> = onOutputItemDone,
) {
const events: LLMEvent[] = []
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
const output = event.response?.output ?? []
const terminalIndexes = Object.fromEntries(
output.flatMap((item, index) => (item.id === undefined ? [] : [[item.id, index] as const])),
)
const emitted = state.outputSequence.filter((id) => terminalIndexes[id] !== undefined)
const expected = output.flatMap((item) =>
item.id !== undefined && state.outputIndexes[item.id] !== undefined ? [item.id] : [],
)
const missingBeforeEmitted = output.some(
(item, index) =>
item.id !== undefined &&
state.outputIndexes[item.id] === undefined &&
Object.values(state.outputIndexes).some((seen) => seen > index),
)
if (missingBeforeEmitted || emitted.some((id, index) => id !== expected[index]))
return yield* ProviderShared.eventError(
state.id,
`${state.name} terminal output conflicts with the streamed output order`,
)
const reconciled = yield* output.reduce<Effect.Effect<ParserState, LLMError>>(
(effect, item) =>
effect.pipe(
Effect.flatMap(
Effect.fnUntraced(function* (current) {
const [next, nextEvents] = yield* onItemDone(current, { ...event, item })
events.push(...nextEvents)
return next
}),
),
),
Effect.succeed(state),
)
const lifecycle = Lifecycle.finish(reconciled.lifecycle, events, {
reason: {
normalized: mapFinishReason(event, state.hasFunctionCall),
normalized: mapFinishReason(event, reconciled.hasFunctionCall),
raw: event.response?.incomplete_details?.reason,
},
usage: mapUsage(event.response?.usage, state.providerMetadataKey),
@@ -956,8 +1242,8 @@ const onResponseFinish = (state: ParserState, event: Event): StepResult => {
})
: undefined,
})
return [{ ...state, lifecycle }, events]
}
return [{ ...reconciled, lifecycle }, events] satisfies StepResult
})
// Build a single human-readable message from whatever the provider supplied.
// When both code and message are present, prefix the code so consumers see
@@ -982,14 +1268,26 @@ const providerError = (state: ParserState, event: Event, fallback: string) => {
})
}
export const step = (state: ParserState, event: Event) => {
export const step = (state: ParserState, event: Event): Effect.Effect<StepResult, LLMError> => {
const outputItemID = event.item_id ?? event.item?.id
if (
outputItemID &&
event.output_index !== undefined &&
state.outputIndexes[outputItemID] === undefined
)
return step(
{
...state,
outputIndexes: { ...state.outputIndexes, [outputItemID]: event.output_index },
outputSequence: [...state.outputSequence, outputItemID],
},
{ ...event, output_index: undefined },
)
if (event.type === "response.output_text.delta" || event.type === "response.output_text.done") {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(
event.type === "response.output_text.delta"
? onOutputTextDelta(state, event, event.item_id)
: onOutputTextDone(state, event, event.item_id),
)
return event.type === "response.output_text.delta"
? Effect.succeed(onOutputTextDelta(state, event, event.item_id))
: onOutputTextDone(state, event, event.item_id)
}
if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_summary_text.delta") {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
@@ -997,7 +1295,7 @@ export const step = (state: ParserState, event: Event) => {
}
if (event.type === "response.reasoning.done" || event.type === "response.reasoning_summary_text.done") {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(onReasoningDone(state, event))
return onReasoningDone(state, event)
}
if (event.type === "response.reasoning_summary_part.added")
return event.item_id
@@ -1013,13 +1311,29 @@ export const step = (state: ParserState, event: Event) => {
return Effect.succeed(onOutputItemAdded(state, event))
}
if (event.type === "response.function_call_arguments.delta") return onFunctionCallArgumentsDelta(state, event)
if (event.type === "response.function_call_arguments.done") {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.arguments === undefined) return ProviderShared.eventError(state.id, `${event.type} is missing arguments`)
const tool = state.tools[event.item_id]
if (!tool)
return ProviderShared.eventError(state.id, `${state.name} completed tool arguments are missing their tool call`)
return Effect.succeed(
[
{
...state,
tools: { ...state.tools, [event.item_id]: { ...tool, input: event.arguments } },
},
NO_EVENTS,
] satisfies StepResult,
)
}
if (event.type === "response.output_item.done") {
if (event.item?.type === "message" && !event.item.id)
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
return onOutputItemDone(state, event)
}
if (event.type === "response.completed" || event.type === "response.incomplete")
return Effect.succeed(onResponseFinish(state, event))
return onResponseFinish(state, event)
if (event.type === "response.failed") return providerError(state, event, `${state.name} response failed`)
if (event.type === "error") return providerError(state, event, `${state.name} stream error`)
return Effect.succeed<StepResult>([state, NO_EVENTS])
@@ -1037,11 +1351,17 @@ export const initial = (request: LLMRequest, extension: Extension = BASE): Parse
name: extension.name,
providerMetadataKey: request.model.route.providerMetadataKey ?? "openresponses",
hasFunctionCall: false,
completedItems: new Set<string>(),
functionArguments: {},
outputIndexes: {},
outputSequence: [],
tools: ToolStream.empty<string>(),
lifecycle: Lifecycle.initial(),
messageItems: new Set<string>(),
messagePhase: (value) => messagePhase(value, extension),
messagePhases: {},
outputText: {},
reasoningText: {},
reasoningItems: {},
store: OpenResponsesOptions.resolve(request).store,
})
+24 -4
View File
@@ -194,6 +194,7 @@ const onHostedToolDone = Effect.fn("OpenAIResponses.onHostedToolDone")(function*
state: OpenResponses.ParserState,
item: HostedToolItem,
) {
if (state.completedItems.has(item.id)) return [state, []] satisfies OpenResponses.StepResult
const tool = HOSTED_TOOLS[item.type]
const providerMetadata = OpenResponses.providerMetadata(state, { itemId: item.id })
const events: LLMEvent[] = []
@@ -214,20 +215,39 @@ const onHostedToolDone = Effect.fn("OpenAIResponses.onHostedToolDone")(function*
providerMetadata,
}),
)
return [{ ...state, lifecycle }, events] satisfies OpenResponses.StepResult
return [
{ ...state, lifecycle, completedItems: new Set([...state.completedItems, item.id]) },
events,
] satisfies OpenResponses.StepResult
})
const onOutputItemDone = (state: OpenResponses.ParserState, event: OpenResponses.Event) =>
event.item && isHostedToolItem(event.item)
? onHostedToolDone(state, event.item)
: OpenResponses.onOutputItemDone(state, event)
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
const outputItemID = event.item_id ?? event.item?.id
if (outputItemID && event.output_index !== undefined && state.outputIndexes[outputItemID] === undefined)
return step(
{
...state,
outputIndexes: { ...state.outputIndexes, [outputItemID]: event.output_index },
outputSequence: [...state.outputSequence, outputItemID],
},
{ ...event, output_index: undefined },
)
if (event.type === "response.reasoning_text.delta" || event.type === "response.reasoning_summary.delta")
return 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.reasoning_text.done" || event.type === "response.reasoning_summary.done")
return event.item_id
? Effect.succeed(OpenResponses.onReasoningDone(state, event))
? OpenResponses.onReasoningDone(state, event)
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done" && event.item && isHostedToolItem(event.item))
return onHostedToolDone(state, event.item)
if (event.type === "response.output_item.done") return onOutputItemDone(state, event)
if (event.type === "response.completed" || event.type === "response.incomplete")
return OpenResponses.onResponseFinish(state, event, onOutputItemDone)
return OpenResponses.step(state, event)
}
File diff suppressed because one or more lines are too long
@@ -882,6 +882,185 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("reconciles authoritative output text done values", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Hello" },
{ type: "response.output_text.done", item_id: "msg_1", text: "Hello!" },
{
type: "response.output_item.done",
item: {
type: "message",
id: "msg_1",
content: [{ type: "output_text", text: "Hello!" }],
},
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.text).toBe("Hello!")
expect(response.events.filter(LLMEvent.is.textDelta)).toEqual([
{ type: "text-delta", id: "msg_1", text: "Hello" },
{ type: "text-delta", id: "msg_1", text: "!" },
])
}),
)
it.effect("reconciles output text independently by content index", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{
type: "response.output_text.delta",
item_id: "msg_1",
content_index: 0,
delta: "First",
},
{
type: "response.output_text.done",
item_id: "msg_1",
content_index: 0,
text: "First.",
},
{
type: "response.output_text.delta",
item_id: "msg_1",
content_index: 1,
delta: "Second",
},
{
type: "response.output_text.done",
item_id: "msg_1",
content_index: 1,
text: "Second.",
},
{
type: "response.output_item.done",
item: {
type: "message",
id: "msg_1",
content: [
{ type: "output_text", text: "First." },
{ type: "output_text", text: "Second." },
],
},
},
{ type: "response.completed", response: {} },
),
),
),
)
expect(response.text).toBe("First.Second.")
}),
)
it.effect("recovers output from the authoritative terminal response", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.completed",
response: {
id: "resp_1",
output: [
{
type: "message",
id: "msg_1",
content: [{ type: "output_text", text: "Terminal only." }],
},
],
},
},
),
),
),
)
expect(response.text).toBe("Terminal only.")
expect(response.events.filter(LLMEvent.is.textDelta)).toEqual([
{ type: "text-delta", id: "msg_1", text: "Terminal only." },
])
}),
)
it.effect("rejects terminal output that cannot be restored to authoritative order", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
output_index: 1,
item: { type: "message", id: "msg_later" },
},
{
type: "response.output_text.delta",
item_id: "msg_later",
output_index: 1,
content_index: 0,
delta: "Later",
},
{
type: "response.completed",
response: {
output: [
{
type: "message",
id: "msg_earlier",
content: [{ type: "output_text", text: "Earlier" }],
},
{
type: "message",
id: "msg_later",
content: [{ type: "output_text", text: "Later" }],
},
],
},
},
),
),
),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain("terminal output conflicts with the streamed output order")
}),
)
it.effect("rejects authoritative text that conflicts with streamed deltas", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Hello" },
{ type: "response.output_text.done", item_id: "msg_1", text: "Goodbye" },
),
),
),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain("completed with content that conflicts with its streamed deltas")
}),
)
it.effect("preserves and replays assistant message phases", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
@@ -1086,6 +1265,110 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("reconciles authoritative reasoning done values", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "thinking" },
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", text: "thinking..." },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.reasoning).toBe("thinking...")
}),
)
it.effect("recovers terminal-only reasoning summaries sequentially", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
type: "response.completed",
response: {
output: [
{
type: "reasoning",
id: "rs_1",
encrypted_content: "encrypted-state",
summary: [
{ type: "summary_text", text: "First" },
{ type: "summary_text", text: "Second" },
],
},
],
},
}),
),
),
)
expect(response.message.content.filter((part) => part.type === "reasoning")).toEqual([
{
type: "reasoning",
text: "First",
providerMetadata: { openai: { itemId: "rs_1" } },
},
{
type: "reasoning",
text: "Second",
providerMetadata: {
openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" },
},
},
])
expect(
response.events
.filter((event) => event.type.startsWith("reasoning-"))
.map((event) => `${event.type}:${event.id}`),
).toEqual([
"reasoning-start:rs_1:0",
"reasoning-delta:rs_1:0",
"reasoning-end:rs_1:0",
"reasoning-start:rs_1:1",
"reasoning-delta:rs_1:1",
"reasoning-end:rs_1:1",
])
}),
)
it.effect("updates completed reasoning metadata without adding a phantom block", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
{
type: "response.output_item.done",
item: { type: "reasoning", id: "rs_1", encrypted_content: "stale", summary: [] },
},
{
type: "response.completed",
response: {
output: [{ type: "reasoning", id: "rs_1", encrypted_content: "final", summary: [] }],
},
},
),
),
),
)
expect(response.message.content.filter((part) => part.type === "reasoning")).toEqual([
{
type: "reasoning",
text: "",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "final" } },
},
])
}),
)
it.effect("preserves encrypted reasoning metadata for continuation", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
@@ -1568,6 +1851,57 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("uses authoritative function argument done values", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
}),
).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.function_call_arguments.delta", item_id: "item_1", delta: '{"query":"stale"}' },
{
type: "response.function_call_arguments.done",
item_id: "item_1",
arguments: '{"query":"authoritative"}',
},
{
type: "response.output_item.done",
item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup" },
},
{
type: "response.completed",
response: {
output: [
{
type: "function_call",
id: "item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"authoritative"}',
},
],
},
},
),
),
),
)
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
id: "call_1",
input: { query: "authoritative" },
})
expect(response.events.filter(LLMEvent.is.toolCall)).toHaveLength(1)
}),
)
it.effect("emits malformed final function arguments as an unexecuted tool error", () =>
Effect.gen(function* () {
const body = sseEvents(
@@ -1642,7 +1976,10 @@ describe("OpenAI Responses route", () => {
const body = sseEvents(
{ type: "response.output_item.added", item },
{ type: "response.output_item.done", item },
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
{
type: "response.completed",
response: { output: [item], usage: { input_tokens: 5, output_tokens: 1 } },
},
)
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
@@ -1701,6 +2038,40 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("recovers hosted tools from the terminal response", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
type: "response.completed",
response: {
output: [
{
type: "image_generation_call",
id: "ig_1",
status: "completed",
result: "AQID",
},
],
},
}),
),
),
)
expect(response.events.find(LLMEvent.is.toolResult)).toMatchObject({
id: "ig_1",
name: "image_generation",
providerExecuted: true,
result: {
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,AQID", mime: "image/png" }],
},
})
}),
)
it.effect("rejects malformed image generation base64", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
+1 -1
View File
@@ -56,7 +56,7 @@
"@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13.tgz",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "1.18.5",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@pierre/trees": "1.0.0-beta.4",
@@ -88,12 +88,8 @@ function SessionTabSlot(props: {
data-titlebar-tab-slot
data-tab-key={props.id}
data-active={props.active()}
class="relative flex min-w-7 flex-shrink"
classList={{
hidden: !session() && !missingSession() && !persisted()?.title,
"w-72 max-w-72": props.active(),
"w-56 max-w-56": !props.active(),
}}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
classList={{ hidden: !session() && !missingSession() && !persisted()?.title }}
>
<TabNavItem
ref={(el) => {
@@ -148,11 +144,7 @@ function DraftTabSlot(props: {
data-titlebar-tab-slot
data-tab-key={props.id}
data-active={props.active()}
class="relative flex min-w-7 flex-shrink"
classList={{
"w-72 max-w-72": props.active(),
"w-56 max-w-56": !props.active(),
}}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
>
<DraftTabItem
ref={(el) => {
-1
View File
@@ -1,6 +1,5 @@
#!/usr/bin/env bun
import "@opentui/solid/runtime-plugin-support"
import { NodeRuntime, NodeServices } from "@effect/platform-node"
import { Effect } from "effect"
import { Commands } from "./commands/commands"
+1 -3
View File
@@ -1,9 +1,7 @@
import type { ModelApi, ProviderApi, WebsearchApi } from "./api/api.js"
import type { ModelApi, ProviderApi } from "./api/api.js"
export type * from "./api/api.js"
export type WebSearchApi<E = never> = WebsearchApi<E>
export interface CatalogApi<E = never> {
readonly provider: ProviderApi<E>
readonly model: ModelApi<E>
-20
View File
@@ -1042,25 +1042,6 @@ export interface DebugApi<E = never> {
readonly location: { readonly list: DebugLocationListOperation<E>; readonly evict: DebugLocationEvictOperation<E> }
}
type Endpoint27_0Request = Parameters<RawClient["server.websearch"]["websearch.providers"]>[0]
export type Endpoint27_0Input = { readonly location?: Endpoint27_0Request["query"]["location"] }
export type Endpoint27_0Output = EffectValue<ReturnType<RawClient["server.websearch"]["websearch.providers"]>>
export type WebsearchProvidersOperation<E = never> = (input?: Endpoint27_0Input) => Effect.Effect<Endpoint27_0Output, E>
type Endpoint27_1Request = Parameters<RawClient["server.websearch"]["websearch.query"]>[0]
export type Endpoint27_1Input = {
readonly location?: Endpoint27_1Request["query"]["location"]
readonly query: Endpoint27_1Request["payload"]["query"]
readonly providerID?: Endpoint27_1Request["payload"]["providerID"]
}
export type Endpoint27_1Output = EffectValue<ReturnType<RawClient["server.websearch"]["websearch.query"]>>
export type WebsearchQueryOperation<E = never> = (input: Endpoint27_1Input) => Effect.Effect<Endpoint27_1Output, E>
export interface WebsearchApi<E = never> {
readonly providers: WebsearchProvidersOperation<E>
readonly query: WebsearchQueryOperation<E>
}
export interface AppApi<E = never> {
readonly health: HealthApi<E>
readonly server: ServerApi<E>
@@ -1089,5 +1070,4 @@ export interface AppApi<E = never> {
readonly projectCopy: ProjectCopyApi<E>
readonly vcs: VcsApi<E>
readonly debug: DebugApi<E>
readonly websearch: WebsearchApi<E>
}
@@ -1244,28 +1244,6 @@ const adaptGroup26 = (raw: RawClient["server.debug"]) => ({
location: { list: Endpoint26_0(raw), evict: Endpoint26_1(raw) },
})
type Endpoint27_0Request = Parameters<RawClient["server.websearch"]["websearch.providers"]>[0]
type Endpoint27_0Input = { readonly location?: Endpoint27_0Request["query"]["location"] }
const Endpoint27_0 = (raw: RawClient["server.websearch"]) => (input?: Endpoint27_0Input) =>
raw["websearch.providers"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint27_1Request = Parameters<RawClient["server.websearch"]["websearch.query"]>[0]
type Endpoint27_1Input = {
readonly location?: Endpoint27_1Request["query"]["location"]
readonly query: Endpoint27_1Request["payload"]["query"]
readonly providerID?: Endpoint27_1Request["payload"]["providerID"]
}
const Endpoint27_1 = (raw: RawClient["server.websearch"]) => (input: Endpoint27_1Input) =>
raw["websearch.query"]({
query: { location: input["location"] },
payload: { query: input["query"], providerID: input["providerID"] },
}).pipe(Effect.mapError(mapClientError))
const adaptGroup27 = (raw: RawClient["server.websearch"]) => ({
providers: Endpoint27_0(raw),
query: Endpoint27_1(raw),
})
const adaptClient = (raw: RawClient) => ({
health: adaptGroup0(raw["server.health"]),
server: adaptGroup1(raw["server.server"]),
@@ -1294,7 +1272,6 @@ const adaptClient = (raw: RawClient) => ({
projectCopy: adaptGroup24(raw["server.projectCopy"]),
vcs: adaptGroup25(raw["server.vcs"]),
debug: adaptGroup26(raw["server.debug"]),
websearch: adaptGroup27(raw["server.websearch"]),
})
export const make = (options?: { readonly baseUrl?: URL | string }) =>
-2
View File
@@ -14,7 +14,6 @@ export type {
PluginApi,
ProviderApi,
ReferenceApi,
WebSearchApi,
SessionApi,
SkillApi,
} from "./api.js"
@@ -36,7 +35,6 @@ export { Provider } from "@opencode-ai/schema/provider"
export { Pty } from "@opencode-ai/schema/pty"
export { Question } from "@opencode-ai/schema/question"
export { Reference } from "@opencode-ai/schema/reference"
export { WebSearch } from "@opencode-ai/schema/websearch"
export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema"
export { Session } from "@opencode-ai/schema/session"
export { SessionPending } from "@opencode-ai/schema/session-pending"
-1
View File
@@ -8,7 +8,6 @@ export type ModelApi = Client["model"]
export type PluginApi = Client["plugin"]
export type ProviderApi = Client["provider"]
export type ReferenceApi = Client["reference"]
export type WebSearchApi = Client["websearch"]
export type SessionApi = Client["session"]
export type SkillApi = Client["skill"]
@@ -207,10 +207,6 @@ import type {
DebugLocationListOutput,
DebugLocationEvictInput,
DebugLocationEvictOutput,
WebsearchProvidersInput,
WebsearchProvidersOutput,
WebsearchQueryInput,
WebsearchQueryOutput,
} from "./types"
import { ClientError } from "./client-error"
@@ -1739,33 +1735,6 @@ export function make(options: ClientOptions) {
),
},
},
websearch: {
providers: (input?: WebsearchProvidersInput, requestOptions?: RequestOptions) =>
request<WebsearchProvidersOutput>(
{
method: "GET",
path: `/api/websearch/provider`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [503, 401, 400],
empty: false,
},
requestOptions,
),
query: (input: WebsearchQueryInput, requestOptions?: RequestOptions) =>
request<WebsearchQueryOutput>(
{
method: "POST",
path: `/api/websearch`,
query: { location: input["location"] },
body: { query: input["query"], providerID: input["providerID"] },
successStatus: 200,
declaredStatuses: [400, 503, 401],
empty: false,
},
requestOptions,
),
},
}
}
@@ -539,10 +539,6 @@ export type VcsFileStatus = {
status: "added" | "deleted" | "modified"
}
export type WebSearchProvider = { id: string; name: string }
export type WebSearchResult = { url: string; title?: string; content?: string; time: { published?: number } }
export type SessionMessageModelSelected = {
id: string
metadata?: { [x: string]: JsonValue }
@@ -1062,15 +1058,6 @@ export type FormCancelled = {
data: { id: string; sessionID: string }
}
export type WebsearchUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "websearch.updated"
location?: LocationRef
data: {}
}
export type SessionIdle = {
id: string
created: number
@@ -2368,7 +2355,6 @@ export type V2Event =
| FormCreated
| FormReplied
| FormCancelled
| WebsearchUpdated
| SessionStatus2
| SessionIdle
| TuiPromptAppend
@@ -4972,27 +4958,3 @@ export type DebugLocationEvictInput = {
}
export type DebugLocationEvictOutput = void
export type WebsearchProvidersInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type WebsearchProvidersOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: Array<WebSearchProvider>
}
export type WebsearchQueryInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly query: { readonly query: string; readonly providerID?: string }["query"]
readonly providerID?: { readonly query: string; readonly providerID?: string }["providerID"]
}
export type WebsearchQueryOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: { providerID: string; results: Array<WebSearchResult> }
}
-1
View File
@@ -9,7 +9,6 @@ export type {
PluginApi,
ProviderApi,
ReferenceApi,
WebSearchApi,
SessionApi,
SkillApi,
} from "./api.js"
+3
View File
@@ -37,6 +37,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
let announced = false
let lastSpawn = 0
let spawnDelay = 5_000
let ownerHeld = false
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) => {
if (announced) return
@@ -64,6 +65,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
const registration = await registered(options.file, true)
if (registration.service !== undefined) {
ownerHeld = false
spawnDelay = 5_000
const service = registration.service
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
@@ -80,6 +82,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
if (failure !== undefined) throw failure
const finished = [...contenders].filter(contenderFinished)
if (finished.some((item) => item.child.exitCode === 0)) {
ownerHeld = true
spawnDelay = Math.min(spawnDelay * 2, 30_000)
}
finished.forEach((item) => contenders.delete(item))
-33
View File
@@ -32,7 +32,6 @@ test("exposes every standard HTTP API group", () => {
"projectCopy",
"vcs",
"debug",
"websearch",
])
expect(Object.keys(client.debug)).toEqual(["location"])
expect(Object.keys(client.debug.location)).toEqual(["list", "evict"])
@@ -42,7 +41,6 @@ test("exposes every standard HTTP API group", () => {
expect(Object.keys(client.integration.connect)).toEqual(["key"])
expect(Object.keys(client.integration.oauth)).toEqual(["connect", "status", "complete", "cancel"])
expect(Object.keys(client.integration.command)).toEqual(["connect", "status", "cancel"])
expect(Object.keys(client.websearch)).toEqual(["providers", "query"])
expect(Object.keys(client.file)).toEqual(["read", "list", "find"])
expect(Object.keys(client.vcs)).toEqual(["status", "diff"])
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"])
@@ -50,37 +48,6 @@ test("exposes every standard HTTP API group", () => {
expect(Object.keys(client.project)).toEqual(["list", "current", "directories"])
})
test("websearch.query uses the public HTTP contract", async () => {
let request: Request | undefined
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
request = input instanceof Request ? input : new Request(input, init)
return Response.json({
location: { directory: "/tmp/project", project: { id: "proj_test", directory: "/tmp/project" } },
data: {
providerID: "exa",
results: [{ url: "https://example.com", title: "Result", content: "result", time: {} }],
},
})
},
})
const result = await client.websearch.query({
query: "opencode",
providerID: "exa",
location: { directory: "/tmp/project" },
})
expect(result.data).toEqual({
providerID: "exa",
results: [{ url: "https://example.com", title: "Result", content: "result", time: {} }],
})
expect(request?.method).toBe("POST")
expect(request?.url).toBe("http://localhost:3000/api/websearch?location%5Bdirectory%5D=%2Ftmp%2Fproject")
expect(await request?.json()).toEqual({ query: "opencode", providerID: "exa" })
})
test("server.get uses the public HTTP contract", async () => {
let request: Request | undefined
const client = OpenCode.make({
@@ -1,6 +1,6 @@
export async function GET() {
const response = await fetch(
"https://raw.githubusercontent.com/anomalyco/opencode/refs/heads/dev/packages/protocol/openapi.json",
"https://raw.githubusercontent.com/anomalyco/opencode/refs/heads/dev/packages/sdk/openapi.json",
)
const json = await response.json()
return json
-4
View File
@@ -27,7 +27,6 @@ import { ConfigModel } from "./config/model"
import { ConfigPlugin } from "./config/plugin"
import { ConfigProvider } from "./config/provider"
import { ConfigReference } from "./config/reference"
import { ConfigWebSearch } from "./config/websearch"
import { ConfigToolOutput } from "./config/tool-output"
import { ConfigVariable } from "./config/variable"
import { ConfigWatcher } from "./config/watcher"
@@ -109,9 +108,6 @@ export class Info extends Schema.Class<Info>("Config.Info")({
references: ConfigReference.Info.pipe(Schema.optional).annotate({
description: "Named local directories or Git repositories available as external context",
}),
websearch: ConfigWebSearch.Info.pipe(Schema.optional).annotate({
description: "Web search provider selection",
}),
plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({
description: "Ordered plugin enablement directives and external package declarations",
}),
@@ -1,27 +0,0 @@
export * as ConfigWebSearchPlugin from "./websearch"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Effect, Stream } from "effect"
import { Config } from "../../config"
export const Plugin = define({
id: "opencode.config.websearch",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const loaded = { entries: yield* config.entries() }
yield* ctx.websearch.transform((websearch) => {
const providerID = Config.latest(loaded.entries, "websearch")?.provider
if (providerID) websearch.default.set(providerID)
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() =>
config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(ctx.websearch.reload()),
),
),
Effect.forkScoped({ startImmediately: true }),
)
}),
})
-8
View File
@@ -1,8 +0,0 @@
export * as ConfigWebSearch from "./websearch"
import { WebSearch } from "@opencode-ai/schema/websearch"
import { Schema } from "effect"
export class Info extends Schema.Class<Info>("ConfigWebSearch.Info")({
provider: WebSearch.ID,
}) {}
-2
View File
@@ -29,7 +29,6 @@ import { Pty } from "./pty"
import { QuestionV2 } from "./question"
import { Shell } from "./shell"
import { Reference } from "./reference"
import { WebSearch } from "./websearch"
import { ReferenceInstructions } from "./reference/instructions"
import { SessionRunnerLLM } from "./session/runner/llm"
import { SessionRunnerModel } from "./session/runner/model"
@@ -57,7 +56,6 @@ const locationServiceNodes = [
AgentV2.node,
CommandV2.node,
Reference.node,
WebSearch.node,
Integration.node,
Catalog.node,
ModelResolver.node,
-2
View File
@@ -14,7 +14,6 @@ import { Integration } from "./integration"
import { Location } from "./location"
import { PluginHost } from "./plugin/host"
import { PluginRuntime } from "./plugin/runtime"
import { WebSearch } from "./websearch"
import { Reference } from "./reference"
import { SkillV2 } from "./skill"
import { State } from "./state"
@@ -159,6 +158,5 @@ export const node = makeLocationNode({
ToolHooks.node,
PluginHooks.node,
PluginRuntime.node,
WebSearch.node,
],
})
+74 -79
View File
@@ -1,8 +1,6 @@
export * as PluginHost from "./host"
import { Plugin } from "@opencode-ai/plugin/v2/effect"
import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
import type { CredentialOAuth } from "@opencode-ai/sdk/v2/types"
import type { Plugin } from "@opencode-ai/plugin/v2/effect"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
import { App } from "../app"
import { Effect, Schema, Stream } from "effect"
@@ -25,7 +23,6 @@ import { Tool } from "../tool/tool"
import { Tools } from "../tool/tools"
import { ToolHooks } from "../tool/hooks"
import { WorkspaceV2 } from "../workspace"
import { WebSearch } from "../websearch"
import { PluginHooks } from "./hooks"
const mutable = <T>(value: T) => value as DeepMutable<T>
@@ -41,7 +38,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
const reference = yield* Reference.Service
const skill = yield* SkillV2.Service
const tools = yield* Tools.Service
const websearch = yield* WebSearch.Service
const toolHooks = yield* ToolHooks.Service
const hooks = yield* PluginHooks.Service
const runtime = yield* PluginRuntime.Service
@@ -251,7 +247,79 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
remove: (id) => draft.remove(Integration.ID.make(id)),
method: {
list: (id) => mutable(draft.method.list(Integration.ID.make(id))),
update: (input) => draft.method.update(methodImplementation(input)),
update: (input) => {
if ("authorize" in input) {
const methodID = Integration.MethodID.make(input.method.id)
const refresh = input.refresh
draft.method.update({
integrationID: Integration.ID.make(input.integrationID),
method: { ...input.method, id: methodID },
authorize: (inputs) =>
input.authorize(inputs).pipe(
Effect.map((authorization) => {
if (authorization.mode === "auto") {
return {
...authorization,
callback: authorization.callback.pipe(
Effect.map((credential) =>
Credential.OAuth.make({
...credential,
methodID: Integration.MethodID.make(credential.methodID),
}),
),
),
}
}
return {
...authorization,
callback: (code: string) =>
authorization.callback(code).pipe(
Effect.map((credential) =>
Credential.OAuth.make({
...credential,
methodID: Integration.MethodID.make(credential.methodID),
}),
),
),
}
}),
),
...(refresh
? {
refresh: (value: Credential.OAuth) =>
refresh(value).pipe(
Effect.map((next) =>
Credential.OAuth.make({
...next,
methodID: Integration.MethodID.make(next.methodID),
}),
),
),
}
: {}),
...(input.label ? { label: input.label } : {}),
})
return
}
if (input.method.type === "env") {
draft.method.update({
integrationID: Integration.ID.make(input.integrationID),
method: { type: "env", names: input.method.names },
})
return
}
if (input.method.type === "command") {
draft.method.update({
integrationID: Integration.ID.make(input.integrationID),
method: Schema.decodeUnknownSync(Integration.CommandMethod)(input.method),
})
return
}
draft.method.update({
integrationID: Integration.ID.make(input.integrationID),
method: { type: "key", label: input.method.label },
})
},
remove: (id, method) =>
draft.method.remove(Integration.ID.make(id), Schema.decodeUnknownSync(Integration.Method)(method)),
},
@@ -362,32 +430,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
})
},
},
websearch: {
providers: () => response(websearch.providers()),
query: (input) =>
response(
websearch.query({
query: input.query,
providerID: input.providerID === undefined ? undefined : WebSearch.ID.make(input.providerID),
}),
),
reload: websearch.reload,
transform: (callback) =>
websearch.transform((draft) => {
callback({
add: (definition) =>
draft.add({
id: WebSearch.ID.make(definition.id),
name: definition.name,
execute: definition.execute,
}),
default: {
get: draft.default.get,
set: (providerID) => draft.default.set(WebSearch.ID.make(providerID)),
},
})
}),
},
session: {
hook: (name, callback) => hooks.register("session", name, callback),
create: (input) =>
@@ -407,50 +449,3 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
},
} satisfies Plugin.Context
})
function methodImplementation(input: IntegrationMethodRegistration): Integration.Implementation {
if ("authorize" in input) {
const refresh = input.refresh
return {
integrationID: Integration.ID.make(input.integrationID),
method: { ...input.method, id: Integration.MethodID.make(input.method.id) },
authorize: (inputs) =>
input.authorize(inputs).pipe(
Effect.map((authorization) => {
if (authorization.mode === "auto") {
return {
...authorization,
callback: authorization.callback.pipe(Effect.map(credential)),
}
}
return {
...authorization,
callback: (code: string) => authorization.callback(code).pipe(Effect.map(credential)),
}
}),
),
...(refresh ? { refresh: (value: Credential.OAuth) => refresh(value).pipe(Effect.map(credential)) } : {}),
...(input.label ? { label: input.label } : {}),
}
}
if (input.method.type === "env") {
return {
integrationID: Integration.ID.make(input.integrationID),
method: { type: "env", names: input.method.names },
}
}
if (input.method.type === "command") {
return {
integrationID: Integration.ID.make(input.integrationID),
method: Schema.decodeUnknownSync(Integration.CommandMethod)(input.method),
}
}
return {
integrationID: Integration.ID.make(input.integrationID),
method: { type: "key", label: input.method.label },
}
}
function credential(value: CredentialOAuth) {
return Credential.OAuth.make({ ...value, methodID: Integration.MethodID.make(value.methodID) })
}
+2 -10
View File
@@ -13,7 +13,6 @@ import { ConfigProviderPlugin } from "../config/plugin/provider"
import { ConfigPolicyPlugin } from "../config/plugin/policy"
import { ConfigReferencePlugin } from "../config/plugin/reference"
import { ConfigSkillPlugin } from "../config/plugin/skill"
import { ConfigWebSearchPlugin } from "../config/plugin/websearch"
import { EventV2 } from "../event"
import { FileMutation } from "../file-mutation"
import { Form } from "../form"
@@ -22,14 +21,12 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Image } from "../image"
import { Integration } from "../integration"
import { KV } from "../kv"
import { Location } from "../location"
import { LocationMutation } from "../location-mutation"
import { ModelsDev } from "../models-dev"
import { Npm } from "@opencode-ai/util/npm"
import { PermissionV2 } from "../permission"
import { Reference } from "../reference"
import { WebSearch } from "../websearch"
import { Ripgrep } from "../ripgrep"
import { SessionInstructions } from "../session/instructions"
import { Shell } from "../shell"
@@ -53,7 +50,6 @@ import { AgentPlugin } from "./agent"
import { CommandPlugin } from "./command"
import { ModelsDevPlugin } from "./models-dev"
import { ProviderPlugins } from "./provider"
import { WebSearchPlugins } from "./websearch"
import { PluginRuntime } from "./runtime"
import { SkillPlugin } from "./skill"
import { SystemPromptPlugin } from "./system-prompt"
@@ -74,7 +70,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const http = yield* HttpClient.HttpClient
const image = yield* Image.Service
const integration = yield* Integration.Service
const kv = yield* KV.Service
const location = yield* Location.Service
const locationMutation = yield* LocationMutation.Service
const models = yield* ModelsDev.Service
@@ -84,12 +79,12 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const form = yield* Form.Service
const read = yield* ReadToolFileSystem.Service
const reference = yield* Reference.Service
const websearch = yield* WebSearch.Service
const ripgrep = yield* Ripgrep.Service
const instructions = yield* SessionInstructions.Service
const shell = yield* Shell.Service
const skill = yield* SkillV2.Service
const tools = yield* Tools.Service
const websearch = yield* WebSearchTool.ConfigService
const wellknown = yield* WellKnown.Service
return Context.mergeAll(
Context.make(AgentV2.Service, agent),
@@ -104,7 +99,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(HttpClient.HttpClient, http),
Context.make(Image.Service, image),
Context.make(Integration.Service, integration),
Context.make(KV.Service, kv),
Context.make(Location.Service, location),
Context.make(LocationMutation.Service, locationMutation),
Context.make(ModelsDev.Service, models),
@@ -114,12 +108,12 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(Form.Service, form),
Context.make(ReadToolFileSystem.Service, read),
Context.make(Reference.Service, reference),
Context.make(WebSearch.Service, websearch),
Context.make(Ripgrep.Service, ripgrep),
Context.make(SessionInstructions.Service, instructions),
Context.make(Shell.Service, shell),
Context.make(SkillV2.Service, skill),
Context.make(Tools.Service, tools),
Context.make(WebSearchTool.ConfigService, websearch),
Context.make(WellKnown.Service, wellknown),
)
})
@@ -138,7 +132,6 @@ const pre = [
...SystemPromptPlugin.Plugins,
ModelsDevPlugin,
...ProviderPlugins,
...WebSearchPlugins,
PatchTool.Plugin,
EditTool.Plugin,
GlobTool.Plugin,
@@ -160,7 +153,6 @@ const post = [
ConfigCommandPlugin.Plugin,
ConfigSkillPlugin.Plugin,
ConfigProviderPlugin.Plugin,
ConfigWebSearchPlugin.Plugin,
VariantPlugin.Plugin,
ConfigPolicyPlugin.Plugin,
] as const satisfies readonly InternalPlugin[]
-30
View File
@@ -12,7 +12,6 @@ import { AbsolutePath } from "@opencode-ai/schema/schema"
import { Session } from "@opencode-ai/schema/session"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Workspace } from "@opencode-ai/schema/workspace"
import { WebSearch } from "@opencode-ai/schema/websearch"
import { DateTime, Effect, Scope, Stream } from "effect"
import { Tool } from "../tool/tool"
@@ -198,31 +197,6 @@ export function fromPromise(plugin: Plugin) {
hook: (name, callback) =>
register(host.tool.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
},
websearch: {
providers: (input) => run(host.websearch.providers(input)),
query: (input) =>
run(
host.websearch.query({
...input,
providerID: input.providerID === undefined ? undefined : WebSearch.ID.make(input.providerID),
}),
),
reload: () => run(host.websearch.reload()),
transform: (callback) =>
register(
host.websearch.transform((draft) => {
callback({
add: (definition) =>
draft.add({
id: definition.id,
name: definition.name,
execute: (input) => attempt((signal) => definition.execute(input, { signal })),
}),
default: draft.default,
})
}),
),
},
session: {
hook: (name, callback) =>
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
@@ -296,10 +270,6 @@ export function fromPromise(plugin: Plugin) {
})
}
function attempt<A>(evaluate: (signal: AbortSignal) => PromiseLike<A>) {
return Effect.tryPromise({ try: evaluate, catch: (cause) => cause })
}
function model(input: { readonly id: string; readonly providerID: string; readonly variant?: string }) {
return Model.Ref.make({
id: Model.ID.make(input.id),
+1 -1
View File
@@ -68,7 +68,7 @@ every field, examples, config locations, and links to dedicated feature guides.
For any request to migrate OpenCode configuration, agents, commands, skills,
plugins, integrations, or other behavior from V1 to V2, read the full
[migration guide](https://v2.opencode.ai/docs/migrate-v1) before acting. In
the repository, its source is `packages/www/content/docs/(Get started)/migrate-v1.mdx`.
the repository, its source is `packages/docs/migrate-v1.mdx`.
V1 config files and `.opencode/` definitions are intended to remain compatible.
The only intentional breaking changes are the server API and plugin API. Native
+2 -4
View File
@@ -20,7 +20,6 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Image } from "../image"
import { Integration } from "../integration"
import { KV } from "../kv"
import { Location } from "../location"
import { LocationMutation } from "../location-mutation"
import { ModelsDev } from "../models-dev"
@@ -35,7 +34,7 @@ import { Shell } from "../shell"
import { SkillV2 } from "../skill"
import { ReadToolFileSystem } from "../tool/read-filesystem"
import { ToolRegistry } from "../tool/registry"
import { WebSearch } from "../websearch"
import { WebSearchTool } from "../tool/websearch"
import { WellKnown } from "../wellknown"
import { PluginInternal } from "./internal"
import { PluginRuntime } from "./runtime"
@@ -290,7 +289,6 @@ export const node = makeLocationNode({
httpClient,
Image.node,
Integration.node,
KV.node,
Location.node,
LocationMutation.node,
ModelsDev.node,
@@ -305,7 +303,7 @@ export const node = makeLocationNode({
Shell.node,
SkillV2.node,
ToolRegistry.toolsNode,
WebSearch.node,
WebSearchTool.configNode,
WellKnown.node,
],
})
-82
View File
@@ -1,82 +0,0 @@
export * as WebSearchExa from "./exa"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Effect, Schema, Scope } from "effect"
import { HttpClient } from "effect/unstable/http"
import { WebSearchMcp } from "./mcp"
export const endpoint = "https://mcp.exa.ai/mcp"
const McpInput = Schema.Struct({
query: Schema.String,
numResults: Schema.Number.pipe(Schema.optional),
})
const McpOutput = Schema.Struct({
content: Schema.Array(
Schema.Struct({
type: Schema.Literal("text"),
text: Schema.String,
_meta: Schema.Struct({ searchTime: Schema.Number }).pipe(Schema.optional),
}),
),
})
export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
id: "opencode.websearch.exa",
effect: Effect.fn("WebSearchExa.Plugin")(function* (ctx) {
const http = yield* HttpClient.HttpClient
yield* ctx.integration.transform((draft) => {
draft.update("exa", (integration) => (integration.name = "Exa"))
draft.method.update({
integrationID: "exa",
method: { type: "key", label: "API key (optional)" },
})
draft.method.update({
integrationID: "exa",
method: { type: "env", names: ["EXA_API_KEY"] },
})
})
yield* ctx.websearch.transform((draft) => {
draft.add({
id: "exa",
name: "Exa",
execute: (input) =>
Effect.gen(function* () {
const connection = yield* ctx.integration.connection.active("exa")
const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined
const url = new URL(endpoint)
if (credential?.type === "key") url.searchParams.set("exaApiKey", credential.key)
const result = yield* WebSearchMcp.call(
http,
url.toString(),
"web_search_exa",
{ input: McpInput, output: McpOutput },
{ query: input.query, numResults: 8 },
)
const content = result?.content.find((item) => item.text)
return content ? parseResults(content.text) : []
}),
})
})
}),
})
function parseResults(text: string) {
return text.split(/\n\n---\n\n/).flatMap((block) => {
const url = block.match(/^URL:\s*(.+)$/m)?.[1]?.trim()
if (!url) return []
const title = block.match(/^Title:\s*(.+)$/m)?.[1]?.trim()
const publishedText = block.match(/^Published:\s*(.+)$/m)?.[1]?.trim()
const published = publishedText && publishedText !== "N/A" ? Date.parse(publishedText) : undefined
const content = block.match(/^(?:Highlights|Text):\s*\n?([\s\S]*)$/m)?.[1]?.trim()
return [
{
url,
...(title && title !== "N/A" ? { title } : {}),
...(content ? { content } : {}),
time: { ...(published !== undefined && Number.isFinite(published) ? { published } : {}) },
},
]
})
}
@@ -1,4 +0,0 @@
import { WebSearchExa } from "./exa"
import { WebSearchParallel } from "./parallel"
export const WebSearchPlugins = [WebSearchExa.Plugin, WebSearchParallel.Plugin] as const
-68
View File
@@ -1,68 +0,0 @@
export * as WebSearchMcp from "./mcp"
import { Duration, Effect, Schema } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { collectBoundedResponseBody } from "../../tool/http-body"
export const MAX_RESPONSE_BYTES = 256 * 1024
export const parseResponse = <F extends Schema.Struct.Fields>(body: string, result: Schema.Struct<F>) => {
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ result })))
const parse = (payload: string) => {
const trimmed = payload.trim()
if (!trimmed.startsWith("{")) return Effect.succeed(undefined)
return decode(trimmed).pipe(Effect.map((response) => response.result))
}
return Effect.gen(function* () {
const trimmed = body.trim()
const direct = trimmed ? yield* parse(trimmed) : undefined
if (direct) return direct
for (const line of body.split("\n")) {
if (!line.startsWith("data: ")) continue
const data = yield* parse(line.substring(6))
if (data) return data
}
})
}
export const call = <F extends Schema.Struct.Fields, R extends Schema.Struct.Fields>(
http: HttpClient.HttpClient,
url: string,
tool: string,
schema: { readonly input: Schema.Struct<F>; readonly output: Schema.Struct<R> },
value: Schema.Struct.Type<F>,
headers: Record<string, string> = {},
) =>
Effect.gen(function* () {
const request = yield* HttpClientRequest.post(url).pipe(
HttpClientRequest.accept("application/json, text/event-stream"),
HttpClientRequest.setHeaders(headers),
HttpClientRequest.schemaBodyJson(
Schema.Struct({
jsonrpc: Schema.Literal("2.0"),
id: Schema.Literal(1),
method: Schema.Literal("tools/call"),
params: Schema.Struct({ name: Schema.String, arguments: schema.input }),
}),
)({
jsonrpc: "2.0" as const,
id: 1 as const,
method: "tools/call" as const,
params: { name: tool, arguments: value },
}),
)
return yield* Effect.gen(function* () {
const response = yield* HttpClient.filterStatusOk(http).execute(request)
const body = yield* collectBoundedResponseBody(
response,
MAX_RESPONSE_BYTES,
() => new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`),
)
return yield* parseResponse(body.toString("utf8"), schema.output)
}).pipe(
Effect.timeoutOrElse({
duration: Duration.seconds(25),
orElse: () => Effect.fail(new Error(`${tool} request timed out`)),
}),
)
})
@@ -1,103 +0,0 @@
export * as WebSearchParallel from "./parallel"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Effect, Schema, Scope } from "effect"
import { HttpClient } from "effect/unstable/http"
import { App } from "../../app"
import { WebSearchMcp } from "./mcp"
export const endpoint = "https://search.parallel.ai/mcp"
const McpInput = Schema.Struct({
objective: Schema.String,
search_queries: Schema.Array(Schema.String),
model_name: Schema.String.check(Schema.isMaxLength(100)).pipe(Schema.optional),
})
const SearchResponse = Schema.Struct({
search_id: Schema.String,
results: Schema.Array(
Schema.Struct({
url: Schema.String,
title: Schema.NullOr(Schema.String).pipe(Schema.optional),
publish_date: Schema.NullOr(Schema.String).pipe(Schema.optional),
excerpts: Schema.Array(Schema.String),
}),
),
warnings: Schema.NullOr(
Schema.Array(
Schema.Struct({
type: Schema.Literals(["spec_validation_warning", "input_validation_warning", "warning"]),
message: Schema.String,
detail: Schema.NullOr(Schema.Record(Schema.String, Schema.Json)).pipe(Schema.optional),
}),
),
).pipe(Schema.optional),
usage: Schema.NullOr(
Schema.Array(
Schema.Struct({
name: Schema.String,
count: Schema.Int,
}),
),
).pipe(Schema.optional),
session_id: Schema.String,
})
const McpOutput = Schema.Struct({
content: Schema.Array(Schema.Struct({ type: Schema.Literal("text"), text: Schema.String })),
structuredContent: SearchResponse,
})
export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
id: "opencode.websearch.parallel",
effect: Effect.fn("WebSearchParallel.Plugin")(function* (ctx) {
const http = yield* HttpClient.HttpClient
yield* ctx.integration.transform((draft) => {
draft.update("parallel", (integration) => (integration.name = "Parallel"))
draft.method.update({
integrationID: "parallel",
method: { type: "key", label: "API key (optional)" },
})
draft.method.update({
integrationID: "parallel",
method: { type: "env", names: ["PARALLEL_API_KEY"] },
})
})
yield* ctx.websearch.transform((draft) => {
draft.add({
id: "parallel",
name: "Parallel",
execute: (input) =>
Effect.gen(function* () {
const connection = yield* ctx.integration.connection.active("parallel")
const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined
const result = yield* WebSearchMcp.call(
http,
endpoint,
"web_search",
{ input: McpInput, output: McpOutput },
{
objective: input.query,
search_queries: [input.query],
},
{
"User-Agent": App.useragent(ctx.app),
...(credential?.type === "key" ? { Authorization: `Bearer ${credential.key}` } : {}),
},
)
return (
result?.structuredContent.results.map((item) => {
const published = item.publish_date ? Date.parse(item.publish_date) : undefined
return {
url: item.url,
...(item.title ? { title: item.title } : {}),
...(item.excerpts.length ? { content: item.excerpts.join("\n\n") } : {}),
time: { ...(published !== undefined && Number.isFinite(published) ? { published } : {}) },
}
}) ?? []
)
}),
})
})
}),
})
-6
View File
@@ -654,12 +654,6 @@ const layer = Layer.effectDiscard(
input: event.data.input,
timeCreated: event.created,
})
yield* db
.update(SessionTable)
.set({ time_updated: DateTime.toEpochMillis(event.created) })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
}),
)
yield* events.project(SessionEvent.Compaction.Admitted, (event) =>
+9 -14
View File
@@ -18,10 +18,10 @@ export const name = "glob"
export const Input = Schema.Struct({
pattern: FileSystem.GlobInput.fields.pattern.annotate({ description: "Glob pattern to match files against" }),
path: RelativePath.pipe(Schema.optional).annotate({
description: "Directory to search. Defaults to the current working directory.",
description: "Relative directory to search. Defaults to the active Location.",
}),
limit: FileSystem.GlobInput.fields.limit.annotate({
description: `Maximum number of matching files to return (default: ${FileSystem.DEFAULT_SEARCH_LIMIT})`,
description: `Maximum results to return (default: ${FileSystem.DEFAULT_SEARCH_LIMIT})`,
}),
})
@@ -55,14 +55,13 @@ export const Plugin = {
name,
Tool.make({
description:
'Search file paths using a glob pattern (examples: "**/*.ts", "src/**/*.tsx").',
"Find files by glob pattern within the active Location. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
input: Input,
output: Output,
execute: (input, context) =>
Effect.gen(function* () {
const searchPath = input.path === "undefined" || input.path === "null" ? undefined : input.path
const source = { type: "tool" as const, messageID: context.messageID, callID: context.callID }
const target = yield* mutation.resolve({ path: searchPath ?? ".", kind: "directory" })
const target = yield* mutation.resolve({ path: input.path ?? ".", kind: "directory" })
const external = target.externalDirectory
if (external)
yield* permission.assert({
@@ -76,26 +75,22 @@ export const Plugin = {
resources: [input.pattern],
save: ["*"],
metadata: {
root: searchPath ?? ".",
path: searchPath,
root: input.path ?? ".",
path: input.path,
limit: input.limit,
},
sessionID: context.sessionID,
agent: context.agent,
source,
})
const info = yield* fs
yield* fs
.stat(target.canonical)
.pipe(
Effect.catchReason("PlatformError", "NotFound", () =>
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${searchPath ?? "."}` })),
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })),
),
)
if (info.type !== "Directory")
return yield* Effect.fail(
new ToolFailure({ message: `Search path is not a directory: ${searchPath ?? "."}` }),
)
const root = path.resolve(location.directory, searchPath ?? ".")
const root = path.resolve(location.directory, input.path ?? ".")
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
const entries = yield* ripgrep
.glob({
+8 -18
View File
@@ -15,9 +15,7 @@ import { Tool } from "./tool"
export const name = "grep"
export const Input = Schema.Struct({
pattern: FileSystem.GrepInput.fields.pattern.check(
Schema.isMinLength(1, { message: "Pattern must not be empty" }),
).annotate({
pattern: FileSystem.GrepInput.fields.pattern.annotate({
description: "Regex pattern to search for in file contents",
}),
path: RelativePath.pipe(Schema.optional).annotate({
@@ -35,7 +33,7 @@ export const Output = Schema.Array(FileSystem.Match)
type ModelOutput = typeof Output.Encoded
/** Format raw search matches into the familiar concise model output. */
export const toModelOutput = (output: ModelOutput, truncated = false) => {
export const toModelOutput = (output: ModelOutput) => {
const lines = output.length === 0 ? ["No files found"] : [`Found ${output.length} matches`]
let current = ""
for (const match of output) {
@@ -46,11 +44,6 @@ export const toModelOutput = (output: ModelOutput, truncated = false) => {
}
lines.push(` Line ${match.line}: ${match.text}`)
}
if (truncated)
lines.push(
"",
`(Results are truncated: showing first ${output.length} results. Consider using a more specific path or pattern.)`,
)
return lines.join("\n")
}
@@ -96,14 +89,13 @@ export const Plugin = {
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })),
),
)
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
const matches = yield* ripgrep
return yield* ripgrep
.grep({
cwd: info?.type === "Directory" ? target : path.dirname(target),
pattern: input.pattern,
file: info?.type === "File" ? path.basename(target) : undefined,
include: input.include,
limit: limit + 1,
limit: input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT,
})
.pipe(
Effect.map((result) =>
@@ -126,18 +118,16 @@ export const Plugin = {
),
),
)
return { matches: matches.slice(0, limit), truncated: matches.length > limit }
}).pipe(
Effect.map((result) => ({
output: result.matches,
Effect.map((output) => ({
output,
content: toModelOutput(
result.matches.map((match) => ({
output.map((match) => ({
...match,
entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) },
})),
result.truncated,
),
metadata: { matches: result.matches.length, truncated: result.truncated },
metadata: { matches: output.length },
})),
Effect.mapError((error) =>
error instanceof ToolFailure
+2 -2
View File
@@ -17,8 +17,8 @@ export const description = `Use this tool when you need to ask the user question
4. Offer choices to the user about what direction to take.
Usage notes:
- A "Type your own answer" option is added automatically; don't include a separate option for free form answers
- Set \`multiple: true\` to allow selecting more than one option
- When \`custom\` is enabled (default), a "Type your own answer" option is added automatically; don't include "Other" or catch-all options
- Answers are returned as arrays of labels; set \`multiple: true\` to allow selecting more than one
- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label`
export const Input = Schema.Struct({
+3 -1
View File
@@ -22,7 +22,9 @@ export const Output = Schema.Struct({
output: Schema.String,
})
export const description = [
"Load a specialized skill's instructions and resources into the current conversation when the task at hand matches its description.",
"Load a specialized skill when the task at hand matches one of the available skills in the instructions.",
"",
"Use this tool to inject the skill's instructions and resources into the current conversation. The output may contain detailed workflow guidance as well as references to scripts, files, etc. in the same directory as the skill.",
"",
"The skill ID must match one of the available skills in the instructions.",
].join("\n")
+212 -72
View File
@@ -2,122 +2,262 @@ export * as WebSearchTool from "./websearch"
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
import { Form } from "../form"
import { KV } from "../kv"
import { Context, Duration, Effect, Layer, Schema } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { App } from "../app"
import { PositiveInt } from "../schema"
import { PermissionV2 } from "../permission"
import { WebSearch } from "../websearch"
import { Tool } from "./tool"
import { collectBoundedResponseBody } from "./http-body"
import { checksum } from "../util/encode"
export const name = "websearch"
export const NO_RESULTS = "No search results found. Please try a different query."
export const EXA_URL = "https://mcp.exa.ai/mcp"
export const PARALLEL_URL = "https://search.parallel.ai/mcp"
export const MAX_NUM_RESULTS = 20
export const MAX_CONTEXT_CHARACTERS = 50_000
export const MAX_RESPONSE_BYTES = 256 * 1024
export const description = `Search the web using the user's selected search integration. Use this for current information beyond knowledge cutoff.
/**
* Provider-independent local web search retained in V2 core for launch parity.
* This invokes the legacy Exa/Parallel product backends itself. It is distinct
* from provider-hosted web search tools, which remain route-owned and execute
* at the model provider. Ownership of this compromise can be revisited later.
*/
export const description = `Search the web using the session's local web search provider. Use this for current information beyond knowledge cutoff.
This is a provider-independent local tool backed by Exa or Parallel. Provider-hosted web search tools are separate and execute at the model provider.
Optional controls support result count, live crawling ('fallback' or 'preferred'), search type ('auto', 'fast', or 'deep'), and maximum context characters.
The current year is ${new Date().getFullYear()}. Use this year when searching for recent information or current events.`
export const Input = Schema.Struct({
query: Schema.String.annotate({ description: "Websearch query" }),
numResults: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_NUM_RESULTS))).annotate({
description: `Number of search results to return (default: 8, maximum: ${MAX_NUM_RESULTS})`,
}),
livecrawl: Schema.optional(Schema.Literals(["fallback", "preferred"])).annotate({
description:
"Live crawl mode - 'fallback': use live crawling as backup if cached unavailable, 'preferred': prioritize live crawling (default: 'fallback')",
}),
type: Schema.optional(Schema.Literals(["auto", "fast", "deep"])).annotate({
description: "Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search",
}),
contextMaxCharacters: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_CONTEXT_CHARACTERS))).annotate(
{
description: `Maximum characters for context string optimized for models (default: 10000, maximum: ${MAX_CONTEXT_CHARACTERS})`,
},
),
})
export const Provider = Schema.Literals(["exa", "parallel"])
export type Provider = typeof Provider.Type
export interface Config {
readonly provider?: Provider
readonly enableExa: boolean
readonly enableParallel: boolean
readonly exaApiKey?: string
readonly parallelApiKey?: string
}
export class ConfigService extends Context.Service<ConfigService, Config>()("@opencode/v2/WebSearchConfig") {}
/** Isolates the retained product environment contract from the generic tool implementation. */
export const defaultConfigLayer = Layer.sync(ConfigService, () =>
ConfigService.of({
provider:
process.env.OPENCODE_WEBSEARCH_PROVIDER === "exa" || process.env.OPENCODE_WEBSEARCH_PROVIDER === "parallel"
? process.env.OPENCODE_WEBSEARCH_PROVIDER
: undefined,
enableExa:
["1", "true"].includes(process.env.OPENCODE_EXPERIMENTAL?.toLowerCase() ?? "") ||
["1", "true"].includes(process.env.OPENCODE_ENABLE_EXA?.toLowerCase() ?? "") ||
["1", "true"].includes(process.env.OPENCODE_EXPERIMENTAL_EXA?.toLowerCase() ?? ""),
enableParallel:
["1", "true"].includes(process.env.OPENCODE_ENABLE_PARALLEL?.toLowerCase() ?? "") ||
["1", "true"].includes(process.env.OPENCODE_EXPERIMENTAL_PARALLEL?.toLowerCase() ?? ""),
exaApiKey: process.env.EXA_API_KEY,
parallelApiKey: process.env.PARALLEL_API_KEY,
}),
)
export const configNode = makeLocationNode({ service: ConfigService, layer: defaultConfigLayer, deps: [] })
export function selectProvider(
sessionID: string,
flags: Pick<Config, "enableExa" | "enableParallel"> = { enableExa: false, enableParallel: false },
override?: Provider,
): Provider {
if (override) return override
if (flags.enableParallel) return "parallel"
if (flags.enableExa) return "exa"
return Number.parseInt(checksum(sessionID) ?? "0", 36) % 2 === 0 ? "exa" : "parallel"
}
const McpResult = Schema.Struct({
result: Schema.Struct({
content: Schema.Array(Schema.Struct({ type: Schema.String, text: Schema.String })),
}),
})
const decodeMcpResult = Schema.decodeUnknownEffect(Schema.fromJsonString(McpResult))
const parsePayload = (payload: string) =>
Effect.gen(function* () {
const trimmed = payload.trim()
if (!trimmed.startsWith("{")) return undefined
return (yield* decodeMcpResult(trimmed)).result.content.find((item) => item.text)?.text
})
export const parseResponse = Effect.fn("WebSearchTool.parseResponse")(function* (body: string) {
const trimmed = body.trim()
const direct = trimmed ? yield* parsePayload(trimmed) : undefined
if (direct) return direct
for (const line of body.split("\n")) {
if (!line.startsWith("data: ")) continue
const data = yield* parsePayload(line.substring(6))
if (data) return data
}
return undefined
})
const ExaArgs = Schema.Struct({
query: Schema.String,
type: Schema.String,
numResults: Schema.Number,
livecrawl: Schema.String,
contextMaxCharacters: Schema.optional(Schema.Number),
})
const ParallelArgs = Schema.Struct({
objective: Schema.String,
search_queries: Schema.Array(Schema.String),
session_id: Schema.String,
})
const McpRequest = <F extends Schema.Struct.Fields>(args: Schema.Struct<F>) =>
Schema.Struct({
jsonrpc: Schema.Literal("2.0"),
id: Schema.Literal(1),
method: Schema.Literal("tools/call"),
params: Schema.Struct({ name: Schema.String, arguments: args }),
})
const exaUrl = (apiKey: string | undefined) => {
if (!apiKey) return EXA_URL
const url = new URL(EXA_URL)
url.searchParams.set("exaApiKey", apiKey)
return url.toString()
}
const callMcp = <F extends Schema.Struct.Fields>(
http: HttpClient.HttpClient,
url: string,
tool: string,
args: Schema.Struct<F>,
value: Schema.Struct.Type<F>,
headers: Record<string, string> = {},
) =>
Effect.gen(function* () {
const request = yield* HttpClientRequest.post(url).pipe(
HttpClientRequest.accept("application/json, text/event-stream"),
HttpClientRequest.setHeaders(headers),
HttpClientRequest.schemaBodyJson(McpRequest(args))({
jsonrpc: "2.0" as const,
id: 1 as const,
method: "tools/call" as const,
params: { name: tool, arguments: value },
}),
)
return yield* Effect.gen(function* () {
const response = yield* HttpClient.filterStatusOk(http).execute(request)
const body = yield* collectBoundedResponseBody(
response,
MAX_RESPONSE_BYTES,
() => new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`),
)
return yield* parseResponse(body.toString("utf8"))
}).pipe(
Effect.timeoutOrElse({
duration: Duration.seconds(25),
orElse: () => Effect.fail(new Error(`${tool} request timed out`)),
}),
)
})
const Output = Schema.Struct({
provider: WebSearch.ID,
results: Schema.Array(WebSearch.Result),
provider: Provider,
text: Schema.String,
})
export const Plugin = {
id: "opencode.tool.websearch",
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) {
const http = yield* HttpClient.HttpClient
const config = yield* ConfigService
const permission = yield* PermissionV2.Service
const forms = yield* Form.Service
const kv = yield* KV.Service
yield* ctx.tool
.transform((draft) =>
draft.add(
name,
{
Tool.make({
description,
input: Input,
output: Output,
execute: (input, context) =>
Effect.gen(function* () {
execute: (input, context) => {
const provider = selectProvider(context.sessionID, config, config.provider)
return Effect.gen(function* () {
yield* permission.assert({
action: name,
resources: [input.query],
save: ["*"],
metadata: input,
metadata: { ...input, provider },
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.messageID, callID: context.callID },
})
const result = yield* ctx.websearch.query(input).pipe(
Effect.catch((error) => {
if (!Schema.is(WebSearch.ProviderRequiredError)(error)) return Effect.fail(error)
return Effect.gen(function* () {
const providers = (yield* ctx.websearch.providers()).data
if (providers.length === 0) return yield* new WebSearch.ProviderRequiredError()
const response = yield* forms.ask({
sessionID: context.sessionID,
title: "Choose a web search provider",
metadata: { kind: "websearch.provider" },
fields: [
{
key: "provider",
title: "Provider",
description: "This becomes your default and can be changed later in configuration.",
type: "string",
required: true,
custom: false,
options: [
...providers.map((provider) => ({ value: provider.id, label: provider.name })),
{ value: "__disable__", label: "Disable web search" },
],
},
],
const text =
provider === "exa"
? yield* callMcp(http, exaUrl(config.exaApiKey), "web_search_exa", ExaArgs, {
query: input.query,
type: input.type || "auto",
numResults: input.numResults || 8,
livecrawl: input.livecrawl || "fallback",
contextMaxCharacters: input.contextMaxCharacters,
})
if (response.status === "cancelled") return yield* Effect.fail(new Error("Web search cancelled"))
const answer = response.answer.provider
if (answer === "__disable__") {
yield* kv.set("websearch:provider", false)
return yield* new WebSearch.DisabledError()
}
if (typeof answer !== "string" || !providers.some((provider) => provider.id === answer))
return yield* new WebSearch.ProviderRequiredError()
yield* kv.set("websearch:provider", answer)
return yield* ctx.websearch.query(input)
})
}),
)
: yield* callMcp(
http,
PARALLEL_URL,
"web_search",
ParallelArgs,
{
objective: input.query,
search_queries: [input.query],
session_id: context.sessionID,
// V2 invocation context does not safely expose the model yet.
},
{
"User-Agent": App.useragent(ctx.app),
...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}),
},
)
const output = {
provider: result.data.providerID,
results: result.data.results,
provider,
text: text ?? NO_RESULTS,
}
const content = output.results.length
? output.results
.map((result) => {
const title = result.title ?? result.url
const published = result.time.published
? `\nPublished: ${new Date(result.time.published).toISOString()}`
: ""
return `## [${title}](${result.url})${published}${result.content ? `\n\n${result.content}` : ""}`
})
.join("\n\n")
: NO_RESULTS
return { output, content, metadata: { provider: output.provider } }
return { output, content: output.text, metadata: { provider: output.provider } }
}).pipe(
Effect.mapError(
(error) => new ToolFailure({ message: `Unable to search the web for ${input.query}`, error }),
),
),
},
)
},
}),
{ codemode: false },
),
)
.pipe(Effect.orDie)
yield* ctx.session.hook("context", (event) =>
Effect.gen(function* () {
if ((yield* kv.get("websearch:provider")) === false) delete event.tools[name]
}),
)
}),
}
-144
View File
@@ -1,144 +0,0 @@
export * as WebSearch from "./websearch"
import { WebSearch } from "@opencode-ai/schema/websearch"
import { Context, Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { EventV2 } from "./event"
import { KV } from "./kv"
import { State } from "./state"
export const ID = WebSearch.ID
export type ID = WebSearch.ID
export const Provider = WebSearch.Provider
export type Provider = WebSearch.Provider
export const Event = WebSearch.Event
export const Input = WebSearch.Input
export type Input = WebSearch.Input
export type ProviderInput = WebSearch.ProviderInput
export const Result = WebSearch.Result
export type Result = WebSearch.Result
export const Response = WebSearch.Response
export type Response = WebSearch.Response
export interface ProviderImplementation extends Provider {
readonly execute: (input: ProviderInput) => Effect.Effect<readonly Result[], unknown>
}
export class ProviderRequiredError extends Schema.TaggedErrorClass<ProviderRequiredError>()(
"WebSearch.ProviderRequired",
{},
) {}
export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()(
"WebSearch.ProviderNotFound",
{
providerID: ID,
},
) {}
export class DisabledError extends Schema.TaggedErrorClass<DisabledError>()("WebSearch.Disabled", {}) {}
export class RequestError extends Schema.TaggedErrorClass<RequestError>()("WebSearch.Request", {
providerID: ID,
cause: Schema.Defect(),
}) {}
export type Error = ProviderRequiredError | ProviderNotFoundError | DisabledError | RequestError
export interface Interface extends State.Transformable<Draft> {
readonly providers: () => Effect.Effect<readonly Provider[]>
readonly default: () => Effect.Effect<Provider | undefined, DisabledError>
readonly query: (input: Input) => Effect.Effect<Response, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/WebSearch") {}
type Data = {
readonly providers: Map<ID, ProviderImplementation>
defaultProviderID?: ID
}
export type Draft = {
add: (provider: ProviderImplementation) => void
default: {
get: () => ID | undefined
set: (providerID: ID) => void
}
}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2.Service
const kv = yield* KV.Service
const decodeResults = Schema.decodeUnknownEffect(Schema.Array(Result))
const state = State.create<Data, Draft>({
initial: () => ({ providers: new Map() }),
draft: (draft) => ({
add: (provider) => draft.providers.set(provider.id, provider),
default: {
get: () => draft.defaultProviderID,
set: (providerID) => (draft.defaultProviderID = providerID),
},
}),
finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid),
})
const requireProvider = (providers: Map<ID, ProviderImplementation>, providerID: ID) => {
const provider = providers.get(providerID)
return provider ? Effect.succeed(provider) : Effect.fail(new ProviderNotFoundError({ providerID }))
}
const defaultProvider = Effect.fn("WebSearch.default")(function* () {
const data = state.get()
const configured = data.defaultProviderID ? data.providers.get(data.defaultProviderID) : undefined
if (configured) return configured
const stored = yield* kv.get("websearch:provider")
if (stored === false) return yield* new DisabledError()
if (typeof stored !== "string") return
return data.providers.get(ID.make(stored))
})
const resolve = Effect.fn("WebSearch.resolve")(function* (input: Input) {
const providers = state.get().providers
if (input.providerID) return yield* requireProvider(providers, input.providerID)
const provider = yield* defaultProvider()
if (!provider) return yield* new ProviderRequiredError()
return provider
})
return Service.of({
transform: state.transform,
reload: state.reload,
providers: Effect.fn("WebSearch.providers")(function* () {
return Array.from(state.get().providers.values(), (provider) => ({
id: provider.id,
name: provider.name,
})).toSorted((a, b) => a.name.localeCompare(b.name))
}),
default: Effect.fn("WebSearch.defaultInfo")(function* () {
const provider = yield* defaultProvider()
return provider && { id: provider.id, name: provider.name }
}),
query: Effect.fn("WebSearch.query")(function* (input) {
const provider = yield* resolve(input)
const results = yield* provider.execute({ query: input.query }).pipe(
Effect.flatMap(decodeResults),
Effect.mapError((cause) => new RequestError({ providerID: provider.id, cause })),
)
return new Response({ providerID: provider.id, results })
}),
})
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [EventV2.node, KV.node],
})
+36 -21
View File
@@ -121,15 +121,23 @@ describe("Config", () => {
const config = yield* Config.Service
const entries = yield* config.entries()
expect(
entries.flatMap((entry) => (entry.type === "document" && entry.info.shell ? [entry.info.shell] : [])),
entries.flatMap((entry) =>
entry.type === "document" && entry.info.shell ? [entry.info.shell] : [],
),
).toEqual(["global", "explicit", "project", "content"])
expect(Config.latest(entries, "shell")).toBe("content")
}).pipe(
Effect.provide(
testLayer(project, global, project, undefined, undefined, emptyCredentialNode, emptyWellknownNode, {
file: explicit,
content: JSON.stringify({ shell: "content" }),
}),
testLayer(
project,
global,
project,
undefined,
undefined,
emptyCredentialNode,
emptyWellknownNode,
{ file: explicit, content: JSON.stringify({ shell: "content" }) },
),
),
),
),
@@ -147,24 +155,31 @@ describe("Config", () => {
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
return Effect.promise(async () => {
await fs.mkdir(global, { recursive: true })
await fs.mkdir(project, { recursive: true })
await fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ shell: "global" }))
await fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ shell: "project" }))
}).pipe(
Effect.andThen(
Effect.gen(function* () {
const config = yield* Config.Service
expect(Config.latest(yield* config.entries(), "shell")).toBe("global")
}).pipe(
Effect.provide(
testLayer(project, global, project, undefined, undefined, emptyCredentialNode, emptyWellknownNode, {
project: false,
}),
await fs.mkdir(global, { recursive: true })
await fs.mkdir(project, { recursive: true })
await fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ shell: "global" }))
await fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ shell: "project" }))
}).pipe(
Effect.andThen(
Effect.gen(function* () {
const config = yield* Config.Service
expect(Config.latest(yield* config.entries(), "shell")).toBe("global")
}).pipe(
Effect.provide(
testLayer(
project,
global,
project,
undefined,
undefined,
emptyCredentialNode,
emptyWellknownNode,
{ project: false },
),
),
),
),
),
)
)
}),
),
)
+4 -8
View File
@@ -53,17 +53,13 @@ export function waitForCodeModeTool(
* full plugin host. Only the tool domain is live; focused tool tests exercise
* registration, snapshots, and execution through the same path production uses.
*/
export const registerToolPlugin = <R>(
plugin: {
readonly id: string
readonly effect: (context: PluginContext) => Effect.Effect<void, never, R>
},
overrides: Parameters<typeof host>[0] = {},
): Effect.Effect<void, never, R | Tools.Service | Scope.Scope> =>
export const registerToolPlugin = <R>(plugin: {
readonly id: string
readonly effect: (context: PluginContext) => Effect.Effect<void, never, R>
}): Effect.Effect<void, never, R | Tools.Service | Scope.Scope> =>
Effect.gen(function* () {
const tools = yield* Tools.Service
const context = host({
...overrides,
session: {
hook: () => Effect.succeed({ dispose: Effect.void }),
},
-6
View File
@@ -2,7 +2,6 @@ import { AgentV2 } from "@opencode-ai/core/agent"
import { AISDK } from "@opencode-ai/core/aisdk"
import { Catalog } from "@opencode-ai/core/catalog"
import { CommandV2 } from "@opencode-ai/core/command"
import { Config } from "@opencode-ai/core/config"
import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
@@ -10,7 +9,6 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Form } from "@opencode-ai/core/form"
import { Integration } from "@opencode-ai/core/integration"
import { Location } from "@opencode-ai/core/location"
import { Npm } from "@opencode-ai/util/npm"
@@ -21,7 +19,6 @@ import { Reference } from "@opencode-ai/core/reference"
import { SkillV2 } from "@opencode-ai/core/skill"
import { ToolHooks } from "@opencode-ai/core/tool/hooks"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { WebSearch } from "@opencode-ai/core/websearch"
import { Effect, Layer } from "effect"
import { tempLocationLayer } from "../fixture/location"
@@ -42,7 +39,6 @@ export const PluginTestLayer = AppNodeBuilder.build(
Npm.node,
Credential.node,
EventV2.node,
Form.node,
LayerNodePlatform.httpClient,
PluginV2.node,
AgentV2.node,
@@ -56,11 +52,9 @@ export const PluginTestLayer = AppNodeBuilder.build(
SkillV2.node,
ToolHooks.node,
ToolRegistry.toolsNode,
WebSearch.node,
]),
[
[Location.node, tempLocationLayer],
[Npm.node, npmLayer],
[Config.node, Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))],
],
) as unknown as Layer.Layer<unknown, never>
+7 -52
View File
@@ -1,16 +1,11 @@
import { Plugin } from "@opencode-ai/plugin/v2/effect"
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { Project } from "@opencode-ai/core/project"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { WebSearch } from "@opencode-ai/core/websearch"
import type {
CredentialOAuth,
IntegrationCommandMethod,
IntegrationEnvMethod,
IntegrationKeyMethod,
@@ -18,11 +13,11 @@ import type {
} from "@opencode-ai/sdk/v2/types"
import { Effect, Stream } from "effect"
type Overrides = Partial<Omit<Plugin.Context, "options" | "session">> & {
readonly session?: Partial<Plugin.Context["session"]>
type Overrides = Partial<Omit<PluginContext, "options" | "session">> & {
readonly session?: Partial<PluginContext["session"]>
}
export function host(overrides: Overrides = {}): Plugin.Context {
export function host(overrides: Overrides = {}): PluginContext {
return {
app: overrides.app ?? { name: "test", version: "test", channel: "test" },
options: {},
@@ -97,12 +92,6 @@ export function host(overrides: Overrides = {}): Plugin.Context {
transform: () => Effect.die("unused tool.transform"),
hook: () => Effect.die("unused tool.hook"),
},
websearch: overrides.websearch ?? {
providers: () => Effect.die("unused websearch.providers"),
query: () => Effect.die("unused websearch.query"),
transform: () => Effect.die("unused websearch.transform"),
reload: () => Effect.die("unused websearch.reload"),
},
session: {
hook: overrides.session?.hook ?? (() => Effect.die("unused session.hook")),
create: overrides.session?.create ?? (() => Effect.die("unused session.create")),
@@ -116,7 +105,7 @@ export function host(overrides: Overrides = {}): Plugin.Context {
}
}
export function agentHost(agent: AgentV2.Interface): Plugin.Context["agent"] {
export function agentHost(agent: AgentV2.Interface): PluginContext["agent"] {
return {
get: (id) => agent.get(AgentV2.ID.make(id)).pipe(Effect.map((value) => value && agentInfo(value))),
list: () => Effect.die("unused agent.list"),
@@ -142,7 +131,7 @@ export function agentHost(agent: AgentV2.Interface): Plugin.Context["agent"] {
}
}
export function catalogHost(catalog: Catalog.Interface): Plugin.Context["catalog"] {
export function catalogHost(catalog: Catalog.Interface): PluginContext["catalog"] {
return {
provider: {
list: () => Effect.die("unused catalog.provider.list"),
@@ -218,7 +207,7 @@ export function catalogHost(catalog: Catalog.Interface): Plugin.Context["catalog
}
}
export function integrationHost(integration: Integration.Interface): Plugin.Context["integration"] {
export function integrationHost(integration: Integration.Interface): PluginContext["integration"] {
return {
list: () => Effect.die("unused integration.list"),
get: () => Effect.die("unused integration.get"),
@@ -340,40 +329,6 @@ export function integrationHost(integration: Integration.Interface): Plugin.Cont
}
}
export function webSearchHost(websearch: WebSearch.Interface): Plugin.Context["websearch"] {
const location = Location.Info.make({
directory: AbsolutePath.make("/tmp/websearch-test"),
project: { id: Project.ID.make("websearch-test"), directory: AbsolutePath.make("/tmp/websearch-test") },
})
return {
providers: () => websearch.providers().pipe(Effect.map((data) => ({ location, data }))),
query: (input) =>
websearch
.query({ query: input.query, providerID: input.providerID && WebSearch.ID.make(input.providerID) })
.pipe(Effect.map((data) => ({ location, data }))),
reload: websearch.reload,
transform: (callback) =>
websearch.transform((draft) => {
callback({
add: (definition) =>
draft.add({
id: WebSearch.ID.make(definition.id),
name: definition.name,
execute: definition.execute,
}),
default: {
get: draft.default.get,
set: (providerID) => draft.default.set(WebSearch.ID.make(providerID)),
},
})
}),
}
}
function oauthCredential(value: CredentialOAuth) {
return Credential.OAuth.make({ ...value, methodID: Integration.MethodID.make(value.methodID) })
}
function method(value: Integration.Method) {
if (value.type === "env") return { type: value.type, names: [...value.names] }
if (value.type === "key") return { type: value.type, label: value.label }
-33
View File
@@ -8,7 +8,6 @@ import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
import { WebSearch } from "@opencode-ai/core/websearch"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionPending } from "@opencode-ai/core/session/pending"
@@ -245,38 +244,6 @@ describe("fromPromise", () => {
}),
)
it.effect("registers a standalone web search provider", () =>
Effect.gen(function* () {
const websearch = yield* WebSearch.Service
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make(plugin)
const promisePlugin = Plugin.define({
id: "promise-websearch",
setup: async (ctx) => {
await ctx.websearch.transform((draft) => {
draft.add({
id: "promise-websearch",
name: "Promise Web Search",
execute: async (input) => [{ url: "https://example.com", content: `promise: ${input.query}`, time: {} }],
})
})
},
})
yield* PluginPromise.fromPromise(promisePlugin).effect(host)
expect(yield* websearch.providers()).toContainEqual({
id: WebSearch.ID.make("promise-websearch"),
name: "Promise Web Search",
})
expect(yield* websearch.query({ query: "effect", providerID: WebSearch.ID.make("promise-websearch") })).toEqual(
new WebSearch.Response({
providerID: WebSearch.ID.make("promise-websearch"),
results: [{ url: "https://example.com", content: "promise: effect", time: {} }],
}),
)
}),
)
it.effect("runs the setup cleanup when the plugin scope closes", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
@@ -1,50 +0,0 @@
import { Effect, Layer } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Credential } from "@opencode-ai/core/credential"
import { Config } from "@opencode-ai/core/config"
import { EventV2 } from "@opencode-ai/core/event"
import { Form } from "@opencode-ai/core/form"
import { Integration } from "@opencode-ai/core/integration"
import { WebSearch } from "@opencode-ai/core/websearch"
import { testEffect } from "../lib/effect"
interface WebSearchRequest {
readonly url: string
readonly headers: Record<string, string>
readonly body: unknown
}
export const requests: WebSearchRequest[] = []
let responseBody = ""
export function resetWebSearchFixture(body: string) {
requests.length = 0
responseBody = body
}
const http = Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.sync(() => {
if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`)
requests.push({
url: request.url,
headers: request.headers,
body: JSON.parse(new TextDecoder().decode(request.body.body)),
})
return HttpClientResponse.fromWeb(request, new Response(responseBody, { status: 200 }))
}),
),
)
export const webSearchIntegrationTest = testEffect(
Layer.merge(
AppNodeBuilder.build(
LayerNode.group([Integration.node, Credential.node, EventV2.node, Form.node, WebSearch.node]),
[[Config.node, Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))]],
),
http,
),
)
-170
View File
@@ -1,170 +0,0 @@
import { beforeEach, describe, expect } from "bun:test"
import { Effect } from "effect"
import { Integration } from "@opencode-ai/core/integration"
import { WebSearch } from "@opencode-ai/core/websearch"
import { WebSearchExa } from "@opencode-ai/core/plugin/websearch/exa"
import { WebSearchParallel } from "@opencode-ai/core/plugin/websearch/parallel"
import { host, integrationHost, webSearchHost } from "./host"
import { requests, resetWebSearchFixture, webSearchIntegrationTest } from "./websearch-fixture"
beforeEach(() => {
resetWebSearchFixture(
`event: message\ndata: ${JSON.stringify({
jsonrpc: "2.0",
id: 1,
result: {
content: [
{
type: "text",
text: "Title: Effect\nURL: https://effect.website\nPublished: 2026-07-25T00:00:00.000Z\nAuthor: N/A\nHighlights:\nEffect documentation",
_meta: { searchTime: 123 },
},
],
},
})}\n\n`,
)
})
const it = webSearchIntegrationTest
describe("built-in web search providers", () => {
it.effect("registers a provider without an integration", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const websearch = yield* WebSearch.Service
const registration = yield* webSearchHost(websearch).transform((draft) => {
draft.add({
id: "test-websearch",
name: "Test Web Search",
execute: (input) => Effect.succeed([{ url: "https://example.com", content: input.query, time: {} }]),
})
})
expect(yield* integrations.get(Integration.ID.make("test-websearch"))).toBeUndefined()
expect(yield* websearch.providers()).toContainEqual({
id: WebSearch.ID.make("test-websearch"),
name: "Test Web Search",
})
yield* registration.dispose
expect(yield* websearch.providers()).not.toContainEqual({
id: WebSearch.ID.make("test-websearch"),
name: "Test Web Search",
})
}),
)
it.effect("registers Exa with its MCP schema", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const websearch = yield* WebSearch.Service
yield* WebSearchExa.Plugin.effect(
host({ integration: integrationHost(integrations), websearch: webSearchHost(websearch) }),
)
const info = yield* integrations.get(Integration.ID.make("exa"))
expect(info).toMatchObject({
id: "exa",
name: "Exa",
methods: [{ type: "key" }, { type: "env", names: ["EXA_API_KEY"] }],
})
yield* integrations.connection.key({ integrationID: Integration.ID.make("exa"), key: "exa secret" })
expect(yield* websearch.query({ query: "effect typescript", providerID: WebSearch.ID.make("exa") })).toEqual(
new WebSearch.Response({
providerID: WebSearch.ID.make("exa"),
results: [
{
url: "https://effect.website",
title: "Effect",
content: "Effect documentation",
time: { published: Date.parse("2026-07-25T00:00:00.000Z") },
},
],
}),
)
expect(requests).toEqual([
{
url: `${WebSearchExa.endpoint}?exaApiKey=exa+secret`,
headers: expect.any(Object),
body: {
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: {
name: "web_search_exa",
arguments: { query: "effect typescript", numResults: 8 },
},
},
},
])
}),
)
it.effect("registers Parallel and keeps its credential in the authorization header", () =>
Effect.gen(function* () {
resetWebSearchFixture(
JSON.stringify({
jsonrpc: "2.0",
id: 1,
result: {
content: [{ type: "text", text: "search results" }],
structuredContent: {
search_id: "search_1",
results: [
{
url: "https://effect.website",
title: "Effect",
publish_date: null,
excerpts: ["Effect documentation"],
},
],
warnings: null,
usage: [{ name: "sku_search", count: 1 }],
session_id: "ses_parallel",
},
},
}),
)
const integrations = yield* Integration.Service
const websearch = yield* WebSearch.Service
yield* WebSearchParallel.Plugin.effect(
host({ integration: integrationHost(integrations), websearch: webSearchHost(websearch) }),
)
yield* integrations.connection.key({ integrationID: Integration.ID.make("parallel"), key: "parallel-secret" })
const output = yield* websearch.query({
query: "effect layers",
providerID: WebSearch.ID.make("parallel"),
})
expect(output).toEqual(
new WebSearch.Response({
providerID: WebSearch.ID.make("parallel"),
results: [
{
url: "https://effect.website",
title: "Effect",
content: "Effect documentation",
time: {},
},
],
}),
)
expect(requests[0]).toMatchObject({
url: WebSearchParallel.endpoint,
headers: { authorization: "Bearer parallel-secret" },
body: {
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: {
name: "web_search",
arguments: {
objective: "effect layers",
search_queries: ["effect layers"],
},
},
},
})
expect(JSON.stringify(output)).not.toContain("parallel-secret")
}),
)
})
-22
View File
@@ -171,28 +171,6 @@ describe("SessionV2.create", () => {
}),
)
it.effect("orders reused sessions by prompt admission time", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const { db } = yield* Database.Service
const reused = yield* session.create({ location, title: "reused" })
const newer = yield* session.create({ location, title: "newer" })
yield* db.update(SessionTable).set({ time_updated: -2 }).where(eq(SessionTable.id, reused.id)).run()
yield* db.update(SessionTable).set({ time_updated: -1 }).where(eq(SessionTable.id, newer.id)).run()
const admitted = yield* events.publish(SessionEvent.InputAdmitted, {
sessionID: reused.id,
inputID: SessionMessage.ID.create(),
input: { type: "user", data: { text: "continue" }, delivery: "steer" },
})
const page = yield* session.list({ directory: location.directory, parentID: null, order: "desc" })
expect(page.data.map((item) => item.id)).toEqual([reused.id, newer.id])
expect(page.data[0]!.time.updated).toEqual(admitted.created)
}),
)
it.effect("filters direct child sessions by parent ID", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
+1 -86
View File
@@ -104,7 +104,7 @@ describe("search tools", () => {
const grep = yield* executeTool(registry, call("grep", { pattern: "needle" }))
expect(glob.metadata).toEqual({ count: FileSystem.DEFAULT_SEARCH_LIMIT, truncated: true })
expect(grep.metadata).toEqual({ matches: FileSystem.DEFAULT_SEARCH_LIMIT, truncated: true })
expect(grep.metadata).toEqual({ matches: FileSystem.DEFAULT_SEARCH_LIMIT })
expect(glob.content).toHaveLength(1)
expect(grep.content).toHaveLength(1)
const globText = glob.content?.[0]?.type === "text" ? glob.content[0].text : ""
@@ -114,9 +114,6 @@ describe("search tools", () => {
`(Results are truncated: showing first ${FileSystem.DEFAULT_SEARCH_LIMIT} results. Consider using a more specific path or pattern.)`,
)
expect(grepText).toStartWith(`Found ${FileSystem.DEFAULT_SEARCH_LIMIT} matches\n`)
expect(grepText).toEndWith(
`(Results are truncated: showing first ${FileSystem.DEFAULT_SEARCH_LIMIT} results. Consider using a more specific path or pattern.)`,
)
}),
)
}),
@@ -124,65 +121,6 @@ describe("search tools", () => {
),
)
it.live("rejects an empty grep pattern", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
withTools(tmp.path, (registry) =>
Effect.gen(function* () {
expect(yield* executeTool(registry, call("grep", { pattern: "" }))).toEqual({
status: "error",
error: {
type: "tool.execution",
message: 'Invalid tool input: Pattern must not be empty\n at ["pattern"]',
},
})
}),
),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("handles explicit grep file and directory paths", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.promise(() =>
Promise.all([
fs.writeFile(path.join(tmp.path, "target.txt"), "needle\n"),
fs.writeFile(path.join(tmp.path, "other.txt"), "needle\n"),
]),
).pipe(
Effect.andThen(
withTools(tmp.path, (registry) =>
Effect.gen(function* () {
const file = yield* executeTool(registry, call("grep", { path: "target.txt", pattern: "needle" }))
expect(file).toMatchObject({
status: "completed",
output: [{ entry: { path: "target.txt" }, line: 1, text: "needle\n" }],
metadata: { matches: 1, truncated: false },
})
const directory = yield* executeTool(registry, call("grep", { path: ".", pattern: "needle" }))
expect(directory).toMatchObject({
status: "completed",
metadata: { matches: 2, truncated: false },
})
if (directory.status !== "completed") return
expect(directory.output).toEqual(
expect.arrayContaining([
expect.objectContaining({ entry: expect.objectContaining({ path: "target.txt" }) }),
expect.objectContaining({ entry: expect.objectContaining({ path: "other.txt" }) }),
]),
)
}),
),
),
),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
for (const name of ["glob", "grep"] as const) {
it.live(`${name} reports a missing search path`, () =>
Effect.acquireUseRelease(
@@ -205,29 +143,6 @@ describe("search tools", () => {
)
}
it.live("reports a file used as the glob search path", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.promise(() => fs.writeFile(path.join(tmp.path, "file.txt"), "content\n")).pipe(
Effect.andThen(
withTools(tmp.path, (registry) =>
executeTool(registry, call("glob", { path: "file.txt", pattern: "*" })),
),
),
Effect.tap((result) =>
Effect.sync(() => {
expect(result).toEqual({
status: "error",
error: { type: "tool.execution", message: "Search path is not a directory: file.txt" },
})
}),
),
),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("requires external_directory approval for an explicit external glob path", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
+235 -106
View File
@@ -1,11 +1,10 @@
import { beforeEach, describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { beforeEach, describe, expect, test } from "bun:test"
import { Effect, Layer, Schema } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { Form } from "@opencode-ai/core/form"
import { KV } from "@opencode-ai/core/kv"
import { WebSearch } from "@opencode-ai/core/websearch"
import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { WebSearchTool } from "@opencode-ai/core/tool/websearch"
@@ -15,36 +14,93 @@ import { Image } from "@opencode-ai/core/image"
import { testEffect } from "./lib/effect"
import { imagePassthrough } from "./lib/image"
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
import { webSearchHost } from "./plugin/host"
const webSearchToolNode = makeLocationNode({
name: "test/websearch-tool-plugin",
layer: Layer.effectDiscard(
Effect.gen(function* () {
const websearch = yield* WebSearch.Service
yield* registerToolPlugin(WebSearchTool.Plugin, { websearch: webSearchHost(websearch) })
}),
),
deps: [ToolRegistry.toolsNode, PermissionV2.node, WebSearch.node, Form.node, KV.node],
layer: Layer.effectDiscard(registerToolPlugin(WebSearchTool.Plugin)),
deps: [ToolRegistry.toolsNode, PermissionV2.node, LayerNodePlatform.httpClient, WebSearchTool.configNode],
})
const sessionID = SessionV2.ID.make("ses_websearch_test")
const assertions: PermissionV2.AssertInput[] = []
const queries: WebSearch.Input[] = []
let result = new WebSearch.Response({
providerID: WebSearch.ID.make("exa"),
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
})
const payload = (text: string) =>
JSON.stringify({
jsonrpc: "2.0",
id: 1,
result: { content: [{ type: "text", text }] },
})
beforeEach(() => {
assertions.length = 0
queries.length = 0
result = new WebSearch.Response({
providerID: WebSearch.ID.make("exa"),
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
describe("WebSearchTool provider selection", () => {
test("rejects out-of-range numeric controls", () => {
const decode = Schema.decodeUnknownSync(WebSearchTool.Input)
expect(() => decode({ query: "x", numResults: 0 })).toThrow()
expect(() => decode({ query: "x", numResults: WebSearchTool.MAX_NUM_RESULTS + 1 })).toThrow()
expect(() => decode({ query: "x", contextMaxCharacters: WebSearchTool.MAX_CONTEXT_CHARACTERS + 1 })).toThrow()
})
test("selects a stable provider per session", () => {
expect(WebSearchTool.selectProvider(sessionID)).toBe(WebSearchTool.selectProvider(sessionID))
})
test("supports an explicit operational override", () => {
expect(WebSearchTool.selectProvider(sessionID, { enableExa: false, enableParallel: false }, "parallel")).toBe(
"parallel",
)
expect(WebSearchTool.selectProvider(sessionID, { enableExa: false, enableParallel: false }, "exa")).toBe("exa")
})
test("prefers Parallel when both explicit flags are enabled", () => {
expect(WebSearchTool.selectProvider(sessionID, { enableExa: true, enableParallel: true })).toBe("parallel")
})
test("prefers Exa when only its explicit flag is enabled", () => {
expect(WebSearchTool.selectProvider(sessionID, { enableExa: true, enableParallel: false })).toBe("exa")
})
})
describe("WebSearchTool MCP response parser", () => {
test("parses plain JSON-RPC responses", async () => {
expect(await Effect.runPromise(WebSearchTool.parseResponse(payload("search results")))).toBe("search results")
})
test("parses SSE JSON-RPC responses and ignores non-JSON frames", async () => {
expect(
await Effect.runPromise(
WebSearchTool.parseResponse(`data: [DONE]\nevent: message\ndata: ${payload("search results")}\n\n`),
),
).toBe("search results")
})
})
interface Request {
readonly url: string
readonly headers: Record<string, string>
readonly body: unknown
}
const requests: Request[] = []
const assertions: PermissionV2.AssertInput[] = []
let responseBody = payload("search results")
let makeResponse = () => new Response(responseBody, { status: 200 })
let config: WebSearchTool.Config = { enableExa: false, enableParallel: false }
beforeEach(() => {
responseBody = payload("search results")
makeResponse = () => new Response(responseBody, { status: 200 })
})
const http = Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.sync(() => {
if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`)
requests.push({
url: request.url,
headers: request.headers,
body: JSON.parse(new TextDecoder().decode(request.body.body)),
})
return HttpClientResponse.fromWeb(request, makeResponse())
}),
),
)
const permission = Layer.succeed(
PermissionV2.Service,
PermissionV2.Service.of({
@@ -56,48 +112,33 @@ const permission = Layer.succeed(
list: () => Effect.die("unused"),
}),
)
const websearch = Layer.succeed(
WebSearch.Service,
WebSearch.Service.of({
transform: () => Effect.die("unused"),
reload: () => Effect.die("unused"),
providers: () => Effect.succeed([]),
default: () => Effect.succeed(undefined),
query: (input) =>
Effect.sync(() => {
queries.push(input)
return result
}),
}),
)
const form = Layer.succeed(
Form.Service,
Form.Service.of({
create: () => Effect.die("unused"),
ask: () => Effect.die("unused"),
get: () => Effect.die("unused"),
list: () => Effect.die("unused"),
state: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
cancel: () => Effect.die("unused"),
}),
)
const kv = Layer.succeed(
KV.Service,
KV.Service.of({
get: () => Effect.succeed(undefined),
set: () => Effect.void,
remove: () => Effect.void,
const websearchConfig = Layer.succeed(
WebSearchTool.ConfigService,
WebSearchTool.ConfigService.of({
get provider() {
return config.provider
},
get enableExa() {
return config.enableExa
},
get enableParallel() {
return config.enableParallel
},
get exaApiKey() {
return config.exaApiKey
},
get parallelApiKey() {
return config.parallelApiKey
},
}),
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebSearch.node, webSearchToolNode]),
LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebSearchTool.configNode, webSearchToolNode]),
[
[PermissionV2.node, permission],
[WebSearch.node, websearch],
[Form.node, form],
[KV.node, kv],
[LayerNodePlatform.httpClient, http],
[WebSearchTool.configNode, websearchConfig],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
[Image.node, imagePassthrough],
],
@@ -105,8 +146,12 @@ const it = testEffect(
)
describe("WebSearchTool registration", () => {
it.effect("asserts permission before delegating to WebSearch", () =>
it.effect("registers websearch, asserts query permission, and calls Exa", () =>
Effect.gen(function* () {
requests.length = 0
assertions.length = 0
responseBody = payload("exa results")
config = { provider: "exa", enableExa: false, enableParallel: false }
const registry = yield* ToolRegistry.Service
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["websearch", "execute"])
@@ -116,14 +161,20 @@ describe("WebSearchTool registration", () => {
...toolIdentity,
call: {
type: "tool-call",
id: "call-search",
id: "call-exa",
name: "websearch",
input: { query: "effect typescript" },
input: {
query: "effect typescript",
numResults: 3,
livecrawl: "preferred",
type: "fast",
contextMaxCharacters: 2500,
},
},
}),
).toMatchObject({
status: "completed",
content: [{ type: "text", text: "## [Search results](https://example.com)\n\nsearch results" }],
content: [{ type: "text", text: "exa results" }],
})
expect(assertions).toMatchObject([
{
@@ -131,65 +182,103 @@ describe("WebSearchTool registration", () => {
action: "websearch",
resources: ["effect typescript"],
save: ["*"],
metadata: { query: "effect typescript" },
metadata: {
query: "effect typescript",
numResults: 3,
livecrawl: "preferred",
type: "fast",
contextMaxCharacters: 2500,
provider: "exa",
},
},
])
expect(queries).toEqual([
expect(requests).toEqual([
{
query: "effect typescript",
url: WebSearchTool.EXA_URL,
headers: expect.any(Object),
body: {
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: {
name: "web_search_exa",
arguments: {
query: "effect typescript",
type: "fast",
numResults: 3,
livecrawl: "preferred",
contextMaxCharacters: 2500,
},
},
},
},
])
}),
)
it.effect("keeps normalized results in structured output", () =>
it.effect("calls Parallel with session ID and keeps bearer credentials out of output", () =>
Effect.gen(function* () {
result = new WebSearch.Response({
providerID: WebSearch.ID.make("parallel"),
results: [
{
url: "https://effect.website",
title: "Effect",
content: "parallel results",
time: { published: Date.parse("2026-07-25T00:00:00.000Z") },
},
],
})
requests.length = 0
assertions.length = 0
responseBody = payload("parallel results")
config = { provider: "parallel", enableExa: false, enableParallel: false, parallelApiKey: "parallel-secret" }
const registry = yield* ToolRegistry.Service
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-parallel", name: "websearch", input: { query: "effect layers" } },
}),
).toEqual({
status: "completed",
output: {
provider: "parallel",
results: [
{
url: "https://effect.website",
title: "Effect",
content: "parallel results",
time: { published: Date.parse("2026-07-25T00:00:00.000Z") },
},
],
},
content: [
{
type: "text",
text: "## [Effect](https://effect.website)\nPublished: 2026-07-25T00:00:00.000Z\n\nparallel results",
const settled = yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-parallel", name: "websearch", input: { query: "effect layers" } },
})
expect(requests[0]).toMatchObject({
url: WebSearchTool.PARALLEL_URL,
headers: { authorization: "Bearer parallel-secret" },
body: {
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: {
name: "web_search",
arguments: { objective: "effect layers", search_queries: ["effect layers"], session_id: sessionID },
},
],
},
})
expect(requests[0]?.body).not.toHaveProperty("params.arguments.model_name")
expect(settled).toEqual({
status: "completed",
output: { provider: "parallel", text: "parallel results" },
content: [{ type: "text", text: "parallel results" }],
metadata: { provider: "parallel" },
})
expect(JSON.stringify(settled)).not.toContain("parallel-secret")
}),
)
it.effect("uses the concise no-results fallback", () =>
it.effect("keeps an Exa credential in the transport URL and out of model output", () =>
Effect.gen(function* () {
result = new WebSearch.Response({ providerID: WebSearch.ID.make("exa"), results: [] })
requests.length = 0
assertions.length = 0
responseBody = payload("credentialed exa results")
config = { provider: "exa", enableExa: false, enableParallel: false, exaApiKey: "exa secret" }
const registry = yield* ToolRegistry.Service
const settled = yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-exa-key", name: "websearch", input: { query: "effect schema" } },
})
expect(requests[0]?.url).toBe(`${WebSearchTool.EXA_URL}?exaApiKey=exa+secret`)
expect(JSON.stringify(settled)).not.toContain("exa secret")
}),
)
it.effect("returns the legacy no-results fallback as concise model text", () =>
Effect.gen(function* () {
requests.length = 0
assertions.length = 0
responseBody = ""
config = { provider: "exa", enableExa: false, enableParallel: false }
const registry = yield* ToolRegistry.Service
expect(
@@ -204,4 +293,44 @@ describe("WebSearchTool registration", () => {
})
}),
)
it.effect("rejects oversized MCP response bodies", () =>
Effect.gen(function* () {
requests.length = 0
assertions.length = 0
let chunksRead = 0
let cancelled = false
makeResponse = () =>
new Response(
new ReadableStream({
pull(controller) {
chunksRead++
if (chunksRead === 10) throw new Error("response was not stopped at the byte limit")
controller.enqueue(new Uint8Array(64 * 1024))
},
cancel() {
cancelled = true
},
}),
{ status: 200 },
)
config = { provider: "exa", enableExa: false, enableParallel: false }
const registry = yield* ToolRegistry.Service
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-large-response", name: "websearch", input: { query: "too much" } },
}),
// toSessionError unwraps the "Unable to search the web for <query>" ToolFailure
// to its byte-limit cause message.
).toEqual({
status: "error",
error: { type: "unknown", message: expect.stringContaining("response exceeded") },
})
expect(chunksRead).toBeLessThan(10)
expect(cancelled).toBe(true)
}),
)
})
-129
View File
@@ -1,129 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect, Exit, Scope } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { KV } from "@opencode-ai/core/kv"
import { WebSearch } from "@opencode-ai/core/websearch"
import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([WebSearch.node, EventV2.node, KV.node])))
const register = (id: string) =>
Effect.gen(function* () {
const websearch = yield* WebSearch.Service
const providerID = WebSearch.ID.make(id)
const calls: WebSearch.ProviderInput[] = []
yield* websearch.transform((draft) => {
draft.add({
id: providerID,
name: id.toUpperCase(),
execute: (input) =>
Effect.sync(() => {
calls.push(input)
return [
{
url: `https://${id}.example.com`,
title: input.query,
content: `${id}: ${input.query}`,
time: {},
},
]
}),
})
})
return { providerID, calls }
})
describe("WebSearch", () => {
it.effect("executes an explicit provider without changing the default", () =>
Effect.gen(function* () {
yield* register("exa")
const parallel = yield* register("parallel")
const websearch = yield* WebSearch.Service
expect(yield* websearch.query({ query: "effect", providerID: parallel.providerID })).toEqual(
new WebSearch.Response({
providerID: parallel.providerID,
results: [
{
url: "https://parallel.example.com",
title: "effect",
content: "parallel: effect",
time: {},
},
],
}),
)
expect((yield* websearch.query({ query: "default" }).pipe(Effect.flip))._tag).toBe("WebSearch.ProviderRequired")
expect(parallel.calls).toEqual([{ query: "effect" }])
}),
)
it.effect("requires a provider when no default is set", () =>
Effect.gen(function* () {
yield* register("exa")
yield* register("parallel")
const websearch = yield* WebSearch.Service
expect((yield* websearch.query({ query: "layers" }).pipe(Effect.flip))._tag).toBe("WebSearch.ProviderRequired")
}),
)
it.effect("uses the default set by a transform", () =>
Effect.gen(function* () {
yield* register("exa")
const parallel = yield* register("parallel")
const websearch = yield* WebSearch.Service
yield* websearch.transform((draft) => draft.default.set(parallel.providerID))
expect((yield* websearch.query({ query: "configured" })).providerID).toBe(parallel.providerID)
}),
)
it.effect("uses the provider stored in KV", () =>
Effect.gen(function* () {
yield* register("exa")
const parallel = yield* register("parallel")
const websearch = yield* WebSearch.Service
const kv = yield* KV.Service
yield* kv.set("websearch:provider", parallel.providerID)
expect((yield* websearch.query({ query: "stored" })).providerID).toBe(parallel.providerID)
yield* kv.remove("websearch:provider")
}),
)
it.effect("fails when web search is explicitly disabled", () =>
Effect.gen(function* () {
yield* register("exa")
const websearch = yield* WebSearch.Service
const kv = yield* KV.Service
yield* kv.set("websearch:provider", false)
expect((yield* websearch.query({ query: "disabled" }).pipe(Effect.flip))._tag).toBe("WebSearch.Disabled")
yield* kv.remove("websearch:provider")
}),
)
it.effect("falls back when the configured default is unavailable", () =>
Effect.gen(function* () {
yield* register("exa")
const websearch = yield* WebSearch.Service
yield* websearch.transform((draft) => draft.default.set(WebSearch.ID.make("missing")))
expect((yield* websearch.query({ query: "fallback" }).pipe(Effect.flip))._tag).toBe("WebSearch.ProviderRequired")
}),
)
it.effect("removes scoped provider registrations", () =>
Effect.gen(function* () {
const websearch = yield* WebSearch.Service
const scope = yield* Scope.fork(yield* Scope.Scope)
const provider = yield* register("temporary").pipe(Scope.provide(scope))
expect(yield* websearch.providers()).toContainEqual({ id: provider.providerID, name: "TEMPORARY" })
yield* Scope.close(scope, Exit.void)
expect(yield* websearch.providers()).not.toContainEqual({ id: provider.providerID, name: "TEMPORARY" })
}),
)
})
+22
View File
@@ -0,0 +1,22 @@
# V2 documentation guide
## Structure
- This directory is a standalone Mintlify site deployed from `packages/docs` on the `dev` branch.
- Write documentation in MDX. Every page should have `title` and `description` frontmatter.
- `docs.json` owns site configuration and navigation. Add, move, or remove its page entries whenever the corresponding MDX pages change.
- Put static files in `assets/` and reference them with root-relative paths such as `/assets/example.svg`.
- The API endpoint reference is generated by Mintlify from `openapi.json`; do not duplicate endpoint documentation as hand-written MDX.
- Keep documentation aligned with the V2 packages. Do not use `packages/opencode` as the source of truth unless the task explicitly concerns V1.
## Local development
- At the start of documentation work, launch `bun dev` from `packages/docs` using the shell tool with `background: true`. Never run the dev server in a foreground shell call and do not poll the process; wait for the background completion notification.
- Preview the site at `http://localhost:3333`. Mintlify does not expose a host option and binds the preview to all network interfaces. The server reloads changes to MDX and `docs.json` automatically.
- Use the running preview to verify navigation, links, Mintlify components, code blocks, and desktop and mobile layout.
## Validation
- Run `bun validate` from `packages/docs` after making documentation or configuration changes.
- Run `bun broken-links` from `packages/docs` when pages, navigation, headings, or links change.
- Treat validation errors and broken internal links as blockers. Also verify external links relevant to the change when practical.
+33
View File
@@ -0,0 +1,33 @@
# OpenCode documentation
The V2 documentation is a Mintlify site deployed from `packages/docs` on the `dev` branch.
## Local preview
From this directory, run:
```bash
bun dev
```
The preview opens at `http://localhost:3333` and reloads when MDX or `docs.json` changes.
Validate changes before opening a pull request:
```bash
bun validate
bun broken-links
```
The V2 theme token reference is generated from
`packages/tui/src/theme/v2/schema.ts`. Regenerate it after schema changes:
```bash
bun run generate
```
`bun validate` checks that the committed snippet is current. The repository's
generation workflow also refreshes it on pushes to `dev`, so Mintlify always
receives the generated MDX as part of the published docs tree.
The hosted preview is available at [opencode.mintlify.site](https://opencode.mintlify.site).
@@ -227,10 +227,10 @@ shell commands, `edit` for all edit/write/patch tools, and `subagent` for child
agents. Other tools generally use their tool name, such as `read`, `glob`,
`grep`, `webfetch`, `websearch`, and `skill`.
<Callout type="tip">
<Tip>
Put broad wildcard rules first and exceptions afterward. For example, deny
all subagents first, then allow `explore`.
</Callout>
</Tip>
`~` and `$HOME` are expanded in filesystem resources for `read`, `edit`, and
`external_directory`. Shell resources are raw command text and are not
@@ -274,10 +274,10 @@ The V2 schema accepts per-agent request `headers` and JSON `body` overlays:
}
```
<Callout type="warning">
<Warning>
The current V2 session runner preserves these overlays on the agent
definition but does not yet apply them to model requests. Configure effective
request settings on the provider, model, or model variant instead. Do not use
legacy top-level agent fields such as `temperature`, `top_p`, `prompt`,
`permission`, `tools`, `disable`, or `maxSteps` in new V2 configuration.
</Callout>
</Warning>
+6
View File
@@ -0,0 +1,6 @@
---
title: "API Reference"
description: "OpenCode HTTP API."
---
The endpoint reference is generated from the current OpenCode V2 [OpenAPI specification](/openapi.json).
+5
View File
@@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 18H6V6H18V18Z" fill="#F5F5F5"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M3 3H21V21H3V3ZM8 8V16H16V8H8Z" fill="#3B7DD8"/>
<path d="M16 8H20V12H16V8Z" fill="#FAB283"/>
</svg>

After

Width:  |  Height:  |  Size: 297 B

+10
View File
@@ -0,0 +1,10 @@
<svg width="234" height="42" viewBox="0 0 234 42" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 30H6V18H18V30Z" fill="#4B4646"/><path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="#B7B1B1"/>
<path d="M48 30H36V18H48V30Z" fill="#4B4646"/><path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" fill="#B7B1B1"/>
<path d="M84 24V30H66V24H84Z" fill="#4B4646"/><path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="#B7B1B1"/>
<path d="M108 36H96V18H108V36Z" fill="#4B4646"/><path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" fill="#B7B1B1"/>
<path d="M144 30H126V18H144V30Z" fill="#4B4646"/><path d="M144 12H126V30H144V36H120V6H144V12Z" fill="#F1ECEC"/>
<path d="M168 30H156V18H168V30Z" fill="#4B4646"/><path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" fill="#F1ECEC"/>
<path d="M198 30H186V18H198V30Z" fill="#4B4646"/><path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" fill="#F1ECEC"/>
<path d="M234 24V30H216V24H234Z" fill="#4B4646"/><path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" fill="#F1ECEC"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg width="234" height="42" viewBox="0 0 234 42" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 30H6V18H18V30Z" fill="#CFCECD"/><path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="#656363"/>
<path d="M48 30H36V18H48V30Z" fill="#CFCECD"/><path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" fill="#656363"/>
<path d="M84 24V30H66V24H84Z" fill="#CFCECD"/><path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="#656363"/>
<path d="M108 36H96V18H108V36Z" fill="#CFCECD"/><path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" fill="#656363"/>
<path d="M144 30H126V18H144V30Z" fill="#CFCECD"/><path d="M144 12H126V30H144V36H120V6H144V12Z" fill="#211E1E"/>
<path d="M168 30H156V18H168V30Z" fill="#CFCECD"/><path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" fill="#211E1E"/>
<path d="M198 30H186V18H198V30Z" fill="#CFCECD"/><path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" fill="#211E1E"/>
<path d="M234 24V30H216V24H234Z" fill="#CFCECD"/><path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" fill="#211E1E"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -17,12 +17,12 @@ and other binary prompt attachments are not currently included in the model
request. Some clients may let you select a PDF, but V2 does not yet make that
PDF visible to the model.
<Callout type="warning">
<Warning>
Use a model that supports image input before attaching an image. OpenCode
passes supported image media to the selected provider, but the provider and
model still enforce their own formats, dimensions, file counts, and size
limits. A text-only model may reject the request.
</Callout>
</Warning>
## Add attachments
@@ -131,12 +131,12 @@ All fields are optional:
| `max_height` | `2000` | Maximum height in pixels. Must be a positive integer. |
| `max_base64_bytes` | `5242880` | Maximum byte length of the Base64-encoded image string. Must be a positive integer. |
<Callout type="note">
<Note>
In the current V2 runtime, these settings apply to image media produced by
the built-in `read` tool. Images attached directly through the TUI, desktop,
web, CLI, or API bypass this normalization. Resize direct attachments before
adding them if the provider requires smaller media.
</Callout>
</Note>
The `read` tool recognizes PNG, JPEG, GIF, and WebP by their contents and will
ingest at most 20 MiB of source image bytes. It decodes the image and compares
+183
View File
@@ -0,0 +1,183 @@
---
title: "Client"
description: "Connect an application to the OpenCode HTTP API."
---
`@opencode-ai/client` is the generated TypeScript client for the OpenCode HTTP
API. Use it when your application connects to an OpenCode server over the
network. Its types and methods are generated from the same contract as the
[API reference](/api).
<Warning>
The V2 API and client are beta. Method names, inputs, and outputs may change
before the stable release.
</Warning>
## Install
```sh
bun add @opencode-ai/client@next
```
## Create a client
Create a client with the server URL, then call methods grouped by API resource:
```ts
import { OpenCode } from "@opencode-ai/client"
const client = OpenCode.make({
baseUrl: "http://localhost:4096",
})
const session = await client.session.create({
location: { directory: "/workspace" },
})
await client.session.prompt({
sessionID: session.id,
text: "Review the current changes",
})
```
## Headers and requests
Pass default authentication or application headers to `OpenCode.make` with
`headers`. You can also supply a custom `fetch` implementation. Each operation
accepts request options as its final argument for an `AbortSignal` or
per-request headers.
```ts
const client = OpenCode.make({
baseUrl: "https://opencode.example.com",
headers: {
authorization: `Bearer ${process.env.OPENCODE_TOKEN}`,
},
})
await client.session.list(undefined, {
signal: AbortSignal.timeout(10_000),
})
```
## Stream events
Streaming endpoints return async iterables:
```ts
for await (const event of client.event.subscribe()) {
console.log(event.type)
}
```
## Local background service
The main client entrypoints are browser-compatible and do not include local
process management. In a Node application, import the native Promise service
API from `@opencode-ai/client/service`.
- `Service.discover()` returns a healthy registered endpoint without starting
a process.
- `Service.ensure()` returns a compatible service, starting one when needed.
- `Service.stop()` stops the exact registered service instance.
- `Service.headers(endpoint)` creates the authentication headers for a client.
```ts
import { OpenCode } from "@opencode-ai/client"
import { Service } from "@opencode-ai/client/service"
const endpoint = await Service.ensure()
const client = OpenCode.make({
baseUrl: endpoint.url,
headers: Service.headers(endpoint),
})
const health = await client.health.get()
```
`Service.ensure()` accepts an optional registration file, required version,
service command, and `onStart` callback:
```ts
const endpoint = await Service.ensure({
file: "/var/run/opencode/service.json",
version: "2.0.0",
command: ["opencode", "serve", "--service"],
onStart(reason, previousVersion) {
console.log(reason, previousVersion)
},
})
```
Omit these options to use the standard registration path and
`opencode serve --service` command.
## Effect
OpenCode provides a first-class Effect client through the
`@opencode-ai/client/effect` entrypoint. It returns typed Effects and Streams
and decodes responses into OpenCode schema values.
```sh
bun add @opencode-ai/client@next effect
```
### Create a client
```ts
import { AbsolutePath, Location, OpenCode } from "@opencode-ai/client/effect"
import { Effect } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
const program = Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:4096" })
const session = yield* client.session.create({
location: Location.Ref.make({
directory: AbsolutePath.make("/workspace"),
}),
})
return yield* client.session.get({ sessionID: session.id })
})
const session = await Effect.runPromise(
program.pipe(Effect.provide(FetchHttpClient.layer)),
)
```
Streaming operations, including `client.event.subscribe()` and
`client.session.log(...)`, return Effect `Stream` values.
### Local background service
The Node-only `@opencode-ai/client/effect/service` entrypoint exposes the same
operations as Effect values. Add `@effect/platform-node` and provide its
filesystem layer when running them.
```sh
bun add @effect/platform-node
```
```ts
import { NodeFileSystem } from "@effect/platform-node"
import { OpenCode } from "@opencode-ai/client/effect"
import { Service } from "@opencode-ai/client/effect/service"
import { Effect } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
const program = Effect.gen(function* () {
const endpoint = yield* Service.ensure()
const client = yield* OpenCode.make({
baseUrl: endpoint.url,
headers: Service.headers(endpoint),
})
return yield* client.health.get()
})
const health = await Effect.runPromise(
program.pipe(
Effect.provide(FetchHttpClient.layer),
Effect.provide(NodeFileSystem.layer),
),
)
```
+24
View File
@@ -0,0 +1,24 @@
---
title: "Build"
description: "Build on the engine used by millions daily."
mode: "wide"
---
<CardGroup cols={1}>
<Card title="Extend OpenCode" href="/build/plugins">
Build plugins that add tools, integrations, commands, agents, and custom behavior while keeping the rest of OpenCode
intact.
</Card>
<Card title="Run it as a server" href="/build/client">
Connect to OpenCode with the same client used by the TUI and desktop app, then build any interface, workflow, or agent
experience around it.
</Card>
<Card title="Embed it" href="/build/sdk">
Embed OpenCode directly into your application and build a completely custom agent, interface, or developer product
around it.
</Card>
</CardGroup>
<Warning>
The plugin API, client, and SDK are still being finalized during beta and may change before OpenCode 2.0 is stable.
</Warning>
+446
View File
@@ -0,0 +1,446 @@
---
title: "Plugins"
description: "Extend OpenCode with plugins."
---
Plugins extend OpenCode in-process. They can transform agents, models, commands,
integrations, references, skills, and tools; intercept model requests and tool
execution; and call a subset of the V2 client.
<Warning>
The V2 plugin API is beta. Entrypoints, hooks, draft shapes, and configuration may change before the stable release.
Use the `/v2` exports described on this page.
</Warning>
## Load plugins
Plugins can be loaded from npm packages, explicit local paths, or config
directories. Each module must have one default export containing a unique
plugin `id` and a `setup` function.
### Configuration
Add ordered entries to the `plugins` field in `opencode.json(c)`:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"plugins": [
"opencode-acme-plugin@1.2.0",
"@acme/opencode-plugin",
"./plugins/local.ts",
{
"package": "./plugins/reviewer.ts",
"options": {
"agent": "reviewer",
"strict": true,
},
},
],
}
```
A string is either a package specifier or a local path. Local paths must start
with `./` or `../` and resolve relative to the configuration file containing
the entry. Absolute paths and `file://` URLs are also supported. Both scoped
packages and versioned package specifiers are supported.
Use the object form to pass JSON configuration to the plugin. OpenCode passes
`options` unchanged as `ctx.options`; omitted options become an empty object.
The plugin owns validation and defaults for its options.
See [Config](/config#locations) for configuration locations and precedence.
Entries from all applicable files are processed from lowest to highest
precedence rather than replacing the entire array.
### Local discovery
OpenCode automatically scans this directory in every discovered OpenCode config
directory:
```text
.opencode/plugins/
```
The equivalent global directory is `~/.config/opencode/plugins/`. Direct `.ts`
and `.js` children are loaded. An immediate child directory is also loaded as a
package when OpenCode can resolve a string `exports`, `module`, or `main`
entrypoint, or an `index.ts` or `index.js` file.
A `plugins/` directory beside a project-root `opencode.json` is not discovered
automatically. Put it under `.opencode/`, or add its file explicitly with a
relative config entry.
### Enable and disable
A string beginning with `-` disables plugins by their exported `id`. `*`
matches every ID, and a suffix of `.*` matches an ID prefix. Directives are
applied in order:
```jsonc title="opencode.jsonc"
{
"plugins": ["./plugins/reviewer.ts", "-acme.reviewer", "-opencode.provider.*", "opencode.provider.openai"],
}
```
Package specifiers and local paths locate plugin modules; they are not disable
selectors. Use the `id` from the plugin's default export to disable it. A later
ID entry re-enables a loaded or built-in plugin. Explicit config directives run
after local auto-discovery, so they can disable discovered plugins by ID.
User plugins are activated in configured order between OpenCode's internal
plugin phases. Hooks run sequentially in registration order, and later hooks
observe earlier mutations. Do not depend on the internal phase ordering while
the API is beta.
### Installation and dependencies
OpenCode installs bare package entries and their production dependencies into
an isolated cache. Package installation does not run lifecycle scripts.
Published packages should expose their plugin entrypoint and include every
runtime import in `dependencies`.
Local files and local package directories are imported directly. OpenCode does
**not** install their dependencies. Install dependencies in a `package.json`
visible from the plugin file, for example:
```sh
cd .opencode
bun add @opencode-ai/plugin@next
```
Match the plugin package version to the OpenCode release you target.
Configuration and discovered plugin files under watched config directories are
reloaded when they change. Reloading replaces the active plugin generation and
releases its scoped registrations. Restart OpenCode after changing an npm
package version or a local dependency when no watched file changed.
## Create a plugin
Export the result of `Plugin.define` as the module default:
```ts title=".opencode/plugins/reviewer.ts"
import { Plugin } from "@opencode-ai/plugin/v2"
export default Plugin.define({
id: "acme.reviewer",
setup: async (ctx) => {
const description =
typeof ctx.options.description === "string" ? ctx.options.description : "Reviews code for regressions"
await ctx.agent.transform((agents) => {
agents.update("reviewer", (agent) => {
agent.description = description
agent.mode = "subagent"
})
})
},
})
```
`setup` runs each time the plugin is activated. Register long-lived behavior
during setup; do not wait there on an infinite event stream. It may return a
synchronous or asynchronous cleanup function. OpenCode awaits that cleanup
when the plugin is disabled, reloaded, or shut down:
```ts
setup: async (ctx) => {
const controller = new AbortController()
const task = synchronize(ctx, controller.signal)
return async () => {
controller.abort()
await task
}
}
```
Hook registrations are released automatically with the same plugin scope. Use
the returned cleanup for resources the plugin owns, such as timers, watchers,
connections, and background tasks.
### Context
The plugin context is essentially an [OpenCode server client](/build/client).
Its read and action methods use the same inputs and responses as the client. It
adds plugin-only methods for transforms, runtime hooks, reloads, registrations,
and plugin options.
| Capability | Available operations |
| ---------------------- | -------------------------------------------------------------------------------------------- |
| `ctx.agent` | `list`, `get`, `transform`, `reload` |
| `ctx.catalog.provider` | `list`, `get` |
| `ctx.catalog.model` | `list`, `get`, `default` |
| `ctx.catalog` | `transform`, `reload` |
| `ctx.command` | `list`, `transform`, `reload` |
| `ctx.integration` | `list`, `get`, `connect`, `attempt`, `transform`, `reload`, and connection lookup/resolution |
| `ctx.plugin` | `list` currently active plugin IDs |
| `ctx.reference` | `list`, `transform`, `reload` |
| `ctx.session` | `create`, `get`, `prompt`, `command`, `synthetic`, `interrupt`, and `hook` |
| `ctx.skill` | `list`, `transform`, `reload` |
| `ctx.tool` | `transform` and `hook` |
| `ctx.aisdk` | `hook` |
| `ctx.event` | `subscribe` to the current public server event stream |
| `ctx.options` | Readonly options from the matching config object |
### Transform hooks
Transform hooks let a plugin modify how OpenCode is configured. Use them to add
or remove definitions, override settings, choose defaults, and provide tools or
other sources.
| Transform | Draft operations |
| ----------------------- | ------------------------------------------------------------------------------------------------------- |
| `agent.transform` | `list`, `get`, `default`, `update`, `remove` |
| `catalog.transform` | Provider `list`, `get`, `update`, `remove`; model `get`, `update`, `remove`; default model `get`, `set` |
| `command.transform` | `list`, `get`, `update`, `remove` |
| `integration.transform` | Integration `list`, `get`, `update`, `remove`; method `list`, `update`, `remove` |
| `reference.transform` | `add`, `remove`, `list` |
| `skill.transform` | `source`, `list` |
| `tool.transform` | `add` |
Here's an example that keeps models synced from a remote source:
```js title=".opencode/plugins/remote-models.js"
import { Plugin } from "@opencode-ai/plugin/v2"
export default Plugin.define({
id: "acme.remote-models",
setup: async (ctx) => {
let models = []
await ctx.catalog.transform((catalog) => {
for (const model of models) {
catalog.model.update(model.providerID, model.id, (draft) => Object.assign(draft, model))
}
})
const refresh = async () => {
const response = await fetch("https://example.com/opencode/models.json", {
signal: AbortSignal.timeout(10_000),
})
if (!response.ok) return
models = await response.json()
await ctx.catalog.reload()
}
await refresh()
const timer = setInterval(() => void refresh().catch(console.error), 60_000)
return () => clearInterval(timer)
},
})
```
`ctx.catalog.reload()` replays every catalog transform to derive the new
catalog. Each plugin's logic remains composed with the others, so a later
plugin can still modify models added by an earlier one. The catalog updates
without restarting OpenCode.
### Runtime hooks
Runtime hooks intercept live operations. Their event objects expose specific
mutable fields:
| Hook | Mutable fields |
| ------------------------------------------- | ------------------------------------------------------------------------------ |
| `ctx.aisdk.hook("sdk", callback)` | `sdk`, after inspecting `model`, `package`, and `options` |
| `ctx.aisdk.hook("language", callback)` | `language`, after inspecting `model`, `sdk`, and `options` |
| `ctx.session.hook("request", callback)` | `system`, `messages`, and the `tools` record immediately before model dispatch |
| `ctx.tool.hook("execute.before", callback)` | `input`, before the selected tool executes |
| `ctx.tool.hook("execute.after", callback)` | Terminal `content`, `metadata`, and `outputPaths`; `error` on failure |
For example, remove a tool from selected model requests and normalize another
tool's input:
```ts title=".opencode/plugins/guards.ts"
import { Plugin } from "@opencode-ai/plugin/v2"
export default Plugin.define({
id: "acme.guards",
setup: async (ctx) => {
await ctx.session.hook("request", (event) => {
delete event.tools.write
})
await ctx.tool.hook("execute.before", (event) => {
if (event.tool !== "lookup" || typeof event.input !== "object" || event.input === null) return
event.input = { ...event.input, source: "plugin" }
})
},
})
```
A hook failure fails the operation it intercepts. Keep runtime hooks fast and
handle expected errors inside the callback.
## Examples
### Add a tool
Create an executable tool with `Tool.make`, then register it with a name
and registration options. Define its input with JSON Schema and use an async
executor:
```js title=".opencode/plugins/greeting.js"
import { Plugin } from "@opencode-ai/plugin/v2"
import { Tool } from "@opencode-ai/plugin/v2/tool"
export default Plugin.define({
id: "acme.greeting",
setup: async (ctx) => {
await ctx.tool.transform((tools) => {
tools.add(
"greeting",
Tool.make({
description: "Create a greeting",
input: {
type: "object",
properties: {
name: { type: "string" },
},
required: ["name"],
additionalProperties: false,
},
output: {
type: "object",
properties: { greeting: { type: "string" } },
required: ["greeting"],
additionalProperties: false,
},
execute: async ({ name }) => {
const text = `Hello, ${name}!`
return {
output: { greeting: text },
content: text,
}
},
}),
)
})
},
})
```
Unsupported characters in tool names are normalized to underscores. Namespace
segments must begin with a letter, contain at most 64 letters, digits,
underscores, or hyphens, and are joined with dots. Pass the optional third
argument to `tools.add` to configure the registration with
`{ namespace, codemode }`:
- `namespace` prefixes and groups the exposed tool name.
- `codemode` defaults to `true` and makes the tool available through the
`execute` CodeMode tool. Set `codemode: false` to expose it directly to the
provider.
The executor receives a second context argument containing `sessionID`,
`agent`, `messageID`, `callID`, and `progress`. A tool with `output`
must return `output`; Effect and Standard Schema codecs validate it, while raw
JSON Schema definitions enforce JSON compatibility only. A tool
without `output` returns model-visible `content` instead.
### Add a command
```js title=".opencode/plugins/review-command.js"
import { Plugin } from "@opencode-ai/plugin/v2"
export default Plugin.define({
id: "acme.review-command",
setup: async (ctx) => {
await ctx.command.transform((commands) => {
commands.update("review", (command) => {
command.description = "Review the current changes"
command.template = "Review the current changes for correctness and missing tests."
})
})
},
})
```
### Set the default model
```js title=".opencode/plugins/default-model.js"
import { Plugin } from "@opencode-ai/plugin/v2"
export default Plugin.define({
id: "acme.default-model",
setup: async (ctx) => {
await ctx.catalog.transform((catalog) => {
catalog.model.default.set("anthropic", "claude-sonnet-4-5")
})
},
})
```
## Publish a package
A package plugin uses the same default export as a local plugin. A minimal
manifest is:
```json title="package.json"
{
"name": "opencode-acme-plugin",
"version": "1.0.0",
"type": "module",
"exports": "./src/index.ts",
"dependencies": {
"@opencode-ai/plugin": "next"
}
}
```
Use versions compatible with the OpenCode release you target and test the
installed package, not only a workspace-linked copy. Because the plugin API is
beta, publish compatible plugin updates when V2 entrypoints or contracts
change.
## Verify loading
List active plugin IDs through the V2 API:
```sh
opencode2 api get /api/plugin
```
If a plugin is absent, check the server log described in
[Troubleshooting](/troubleshooting#read-logs). Invalid modules and setup failures are
logged; one failing package does not prevent unrelated valid packages from
being resolved.
## Effect
OpenCode provides a first-class Effect API for plugins through the
`@opencode-ai/plugin/v2/effect` entrypoint. Install `effect` alongside the
plugin package and export an `effect` function instead of `setup`:
```sh
bun add @opencode-ai/plugin@next effect
```
```ts title=".opencode/plugins/reviewer-effect.ts"
import { Plugin } from "@opencode-ai/plugin/v2/effect"
import { Effect } from "effect"
export default Plugin.define({
id: "acme.reviewer-effect",
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.agent.transform((agents) => {
agents.update("reviewer", (agent) => {
agent.description = "Reviews code for regressions"
agent.mode = "subagent"
})
})
}),
})
```
Context operations return Effects. The plugin effect is scoped, so finalizers,
fibers, and registrations are released when the plugin reloads or unloads.
OpenCode does not expose its private Core services to the plugin; use the
capabilities on `ctx`.
Typed tools can use `Schema` from `effect` and `Tool.make` from
`@opencode-ai/plugin/v2/effect/tool`. Effect and Promise plugins use the same
`tools.add(name, tool, options?)` registration shape. Effect executors
return an Effect and may fail with the typed tool failure channel.
+84
View File
@@ -0,0 +1,84 @@
---
title: "SDK"
description: "Embed OpenCode directly in your application."
---
We're working on a general-purpose SDK for embedding OpenCode directly inside
your application. The regular SDK is coming soon.
An Effect-native version is available now for applications built with Effect.
Its current documentation is below. For other applications, run OpenCode as a
server and use the [TypeScript client](/build/client) in the meantime.
## Effect
`@opencode-ai/sdk-next` hosts OpenCode in-process. Unlike the
[network client](/build/client), it assembles the OpenCode server and routes API
calls through its HTTP router in memory. It opens no HTTP listener and adds no
network hop between the client and server.
<Warning>
The V2 SDK is beta and currently private to the OpenCode workspace. It is not
published for external installation yet, and its package name and API may
change before release.
</Warning>
## Create a host
`OpenCode.create()` creates a scoped host. Closing its Effect Scope releases
the router, location services, fibers, and scoped plugin registrations.
```ts
import {
AbsolutePath,
Location,
OpenCode,
} from "@opencode-ai/sdk-next"
import { Effect } from "effect"
const program = Effect.scoped(
Effect.gen(function* () {
const opencode = yield* OpenCode.create()
const session = yield* opencode.sessions.create({
location: Location.Ref.make({
directory: AbsolutePath.make("/workspace"),
}),
})
return yield* opencode.sessions.get({ sessionID: session.id })
}),
)
const session = await Effect.runPromise(program)
```
The embedded host uses the same routes, middleware, codecs, errors, and schema
values as `@opencode-ai/client/effect`. It exposes the full generated client and
adds the convenience aliases `sessions` and `events` for the session and event
groups.
## Use as a service
Use `OpenCode.layer` when the host should be provided through Effect dependency
injection:
```ts
import { OpenCode } from "@opencode-ai/sdk-next"
import { Effect } from "effect"
const program = Effect.gen(function* () {
const opencode = yield* OpenCode.Service
return yield* opencode.sessions.active()
})
const active = await Effect.runPromise(
program.pipe(Effect.provide(OpenCode.layer)),
)
```
## Register plugins
Call `opencode.plugin(...)` to register an embedded V2 plugin. Embedded plugins
use the same discovery and location-scoped activation path as configured
plugins. The SDK also exports `Tool` for plugin-defined tools. See the
[Plugins guide](/build/plugins) for the plugin shape and available hooks.
@@ -137,10 +137,10 @@ project location and inserts its combined output into the template. Argument
interpolation happens first, so avoid placing untrusted arguments inside shell
interpolations.
<Callout type="warning">
<Warning>
Shell interpolations run when the command is evaluated, outside the agent's
tool permission flow. Only use commands from sources you trust.
</Callout>
</Warning>
No other template interpolation is performed. In particular, an `@path`
written into a stored template remains ordinary prompt text; V2 does not
@@ -3,9 +3,9 @@ title: "Config"
description: ""
---
<Callout type="tip">
<Tip>
You shouldn't have to configure OpenCode manually. Ask OpenCode to update its configuration for you.
</Callout>
</Tip>
## Format
+78
View File
@@ -0,0 +1,78 @@
{
"$schema": "https://mintlify.com/docs.json",
"theme": "mint",
"name": "OpenCode",
"description": "OpenCode documentation.",
"colors": {
"primary": "#3B7DD8",
"light": "#3B7DD8",
"dark": "#FAB283"
},
"favicon": "/assets/favicon.svg",
"logo": {
"light": "/assets/logo-light.svg",
"dark": "/assets/logo-dark.svg"
},
"navigation": {
"tabs": [
{
"tab": "Docs",
"groups": [
{
"group": "Get started",
"pages": ["index", "migrate-v1", "config", "troubleshooting"]
},
{
"group": "Configure",
"pages": [
"providers",
"models",
"agents",
"permissions",
"sharing",
"snapshots",
"commands",
"skills",
"instructions",
"mcp-servers",
"attachments",
"compaction",
"warming",
"themes",
"formatters",
"lsp",
"references"
]
}
]
},
{
"tab": "Build",
"pages": ["build/index", "build/plugins", "build/client", "build/sdk"]
},
{
"tab": "API",
"groups": [
{
"group": "Overview",
"pages": ["api/index"]
},
{
"group": "Endpoints",
"openapi": "openapi.json"
}
]
}
],
"global": {}
},
"contextual": {
"options": ["copy", "view", "chatgpt", "claude", "mcp", "cursor", "vscode"]
},
"footer": {
"socials": {
"github": "https://github.com/anomalyco/opencode",
"x": "https://x.com/opencode"
}
}
}
@@ -6,10 +6,10 @@ description: ""
OpenCode V2 accepts formatter configuration, but it does not yet include a
formatter runtime. File writes and edits are not automatically formatted.
<Callout type="warning">
<Warning>
V2 currently has no built-in formatters. The built-in formatter list and
automatic post-edit formatting documented for V1 do not apply to V2.
</Callout>
</Warning>
## Configuration
@@ -1,16 +1,16 @@
---
title: "Get started"
title: "Intro"
description: "Get started with OpenCode."
---
<Callout type="warning">
<Warning>
These docs are for the beta version of OpenCode, which will become OpenCode 2.0. The beta is still changing: we may
wipe your data, things may break, and APIs, configuration, and plugin APIs may change.
</Callout>
</Warning>
## Install
<Callout type="note">The curl install script is not available in beta.</Callout>
<Note>The curl install script is not available in beta.</Note>
You can also install it with the following package managers.
@@ -40,7 +40,7 @@ You can also install it with the following package managers.
The package uses a trusted postinstall script to select the native binary for your platform. The Bun and pnpm commands
above explicitly allow that script to run.
<Callout type="note">During beta, the binary is called `opencode2`.</Callout>
<Note>During beta, the binary is called `opencode2`.</Note>
### Homebrew
@@ -52,11 +52,11 @@ Arch Linux installation is not available in beta.
### Windows
<Callout type="tip">
<Tip>
For the best experience on Windows, install [Windows Subsystem for Linux
(WSL)](https://learn.microsoft.com/windows/wsl/install), open your Linux distribution, and use one of the beta package
manager commands above.
</Callout>
</Tip>
<Tabs>
<Tab title="chocolatey">
@@ -119,7 +119,7 @@ You are now ready to use OpenCode in your project. Here are a few common workflo
Ask OpenCode to explain your codebase.
<Callout type="tip">Use `@` to fuzzy search for files in the project.</Callout>
<Tip>Use `@` to fuzzy search for files in the project.</Tip>
```text
How is authentication handled in @packages/functions/src/api/index.ts
@@ -135,7 +135,7 @@ Create a screen that shows recently deleted notes.
From this screen, the user can restore a note or permanently delete it.
```
<Callout type="tip">Give OpenCode plenty of context and examples.</Callout>
<Tip>Give OpenCode plenty of context and examples.</Tip>
### Undo changes
@@ -43,10 +43,10 @@ If the Location is outside the project root, only the global file is loaded.
Setting `OPENCODE_DISABLE_PROJECT_CONFIG=1` also skips project `AGENTS.md`
discovery but does not disable the global file.
<Callout type="note">
<Note>
Current V2 discovery only recognizes `AGENTS.md`. The `CLAUDE.md` fallback
and related precedence described by older OpenCode documentation do not apply.
</Callout>
</Note>
### Nested instructions
@@ -80,13 +80,13 @@ Configuration is loaded from global through project-local files. If more than
one config defines `instructions`, the highest-precedence, closest config's
entire array is selected; arrays are not merged.
<Callout type="warning">
<Warning>
V2 currently parses and retains this field but does not resolve its entries
into instruction sources. Local files, glob patterns, and HTTP or HTTPS URLs
in `instructions` therefore do not reach the model yet. Use `AGENTS.md` for
active V2 instructions. URL fetching and timeout behavior documented for V1
are not supported by the current V2 implementation.
</Callout>
</Warning>
See [Config](/config) for config locations and general precedence.
@@ -6,12 +6,12 @@ description: ""
Language Server Protocol (LSP) integrations can provide code diagnostics,
symbols, definitions, references, and other language-aware context.
<Callout type="warning">
<Warning>
OpenCode V2 does not yet have an LSP runtime or built-in language servers.
The `lsp` configuration is accepted and preserved, but it does not currently
start or download servers, expose an LSP tool, or add diagnostics to file tool
results.
</Callout>
</Warning>
## Built-in servers
@@ -17,15 +17,15 @@ Existing server config files, agent definitions, command definitions, skills, an
continue to work without changes. If one of these stops working in V2, treat it as a beta compatibility bug rather than
an expected migration requirement.
<Callout type="tip">
<Tip>
Run `/report` if existing V1 functionality does not work in V2. The report skill collects diagnostics and helps you file
a compatibility issue.
</Callout>
</Tip>
<Callout type="warning">
<Warning>
OpenCode 2.0 is in beta. Beta data may be wiped, features may break unintentionally, and the server and plugin APIs may
continue to change.
</Callout>
</Warning>
During the beta, OpenCode V1 and V2 use different executable names. You can keep using `opencode` for V1 while trying V2
with `opencode2`.
@@ -551,7 +551,7 @@ Rename `plugin` to `plugins`. Replace a package-and-options tuple with an object
V2 discovers local plugins from both `.opencode/plugin/` and `.opencode/plugins/`; use `.opencode/plugins/` for V2 files.
Moving a file between these directories does not migrate its implementation.
<Callout type="warning">V1 plugins will not work in V2.</Callout>
<Warning>V1 plugins will not work in V2.</Warning>
The config entry can be translated automatically, but plugin implementation code must be ported to the new API. The V2
plugin API is still being finalized during beta, and detailed plugin migration guidance will be published when it is
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/docs",
"private": true,
"scripts": {
"dev": "bun run generate && bun --bun mint dev --no-open --port 3333",
"generate": "bun script/generate-theme-tokens.ts",
"check:generated": "bun script/generate-theme-tokens.ts --check",
"validate": "bun run check:generated && bun --bun mint validate",
"broken-links": "bun --bun mint broken-links"
},
"devDependencies": {
"effect": "catalog:",
"mint": "4.2.666",
"prettier": "3.6.2"
}
}
@@ -6,11 +6,11 @@ description: ""
Permissions control whether an agent may perform an action on a resource. V2
configuration uses the `permissions` field and an ordered array of rules.
<Callout type="warning">
<Warning>
The V1 object syntax uses different field and action names. Do not use
`permission`, `bash`, or `task` in V2 configuration; use `permissions`,
`shell`, and `subagent`.
</Callout>
</Warning>
## Rule schema
@@ -118,13 +118,13 @@ For `external_directory`, `read`, and `edit` resources, a leading `~`, `~/`,
`$HOME`, or `$HOME/` is expanded when configuration loads. Shell resources are
raw command text and are **not** home-expanded.
<Callout type="warning">
<Warning>
`shell` runs with the host user's filesystem, process, and network authority.
Its resource is raw text, not a parsed command. External command arguments
produce only best-effort warnings; `external_directory` is enforced for the
working directory, not every path embedded in a command. Prefer a narrow
shell allowlist over patterns intended to identify every dangerous command.
</Callout>
</Warning>
Relative mutation paths cannot escape the active Location, and symlink escapes
from inside it are rejected. Explicit external paths are canonicalized before
@@ -55,7 +55,7 @@ When several credential sources exist, OpenCode uses the stored credential first
}
```
<Callout type="warning">Do not commit API keys or authorization headers to your repository.</Callout>
<Warning>Do not commit API keys or authorization headers to your repository.</Warning>
## Configure
@@ -56,11 +56,11 @@ The string shorthand is useful when no other fields are needed:
}
```
<Callout type="note">
<Note>
A shorthand string is treated as a local path only when it starts with `.`,
`/`, or `~`. Use `./docs`, not `docs`; a bare `docs` value is interpreted as
a Git repository.
</Callout>
</Note>
## Git repositories
@@ -114,11 +114,11 @@ Materialization runs asynchronously when references load or reload, so a new
reference can appear before its checkout is ready. Clone and refresh failures
are logged and do not stop other references from loading.
<Callout type="warning">
<Warning>
The cache has one checkout per normalized remote, not one per branch. Do not
configure the same repository at multiple branches; only one branch can be
exposed. Avoid editing cached checkouts because a refresh resets them.
</Callout>
</Warning>
## Description and visibility
@@ -55,7 +55,7 @@ const example = {
} satisfies ThemeDocument
Schema.decodeUnknownSync(ThemeDocument)(example)
const output = await format(
`{/* Generated by packages/www/script/generate-theme-tokens.ts. Do not edit. */}
`{/* Generated by packages/docs/script/generate-theme-tokens.ts. Do not edit. */}
\`\`\`json title="my-theme.json"
${JSON.stringify(example, null, 2)}
@@ -102,7 +102,7 @@ surfaces that need different contrast without changing the base theme.
if (process.argv.includes("--check")) {
const current = await Bun.file(target).text()
if (current === output) process.exit(0)
console.error("Generated theme token documentation is stale. Run `bun run generate` from packages/www.")
console.error("Generated theme token documentation is stale. Run `bun run generate` from packages/docs.")
process.exit(1)
}
@@ -7,10 +7,10 @@ Session sharing is not yet available in OpenCode V2. V2 does not currently
publish sessions, upload conversation history to a sharing service, or create
public links.
<Callout type="warning">
<Warning>
The V2 TUI registers `/share`, but it currently only reports that sharing is unavailable. There is no functional
share/unshare command or server API endpoint.
</Callout>
</Warning>
## Configuration
@@ -58,10 +58,10 @@ Running `/undo` again moves the staged boundary to an earlier user message. Open
immediately before the first undo as the redo baseline, so repeated undos form one wider staged revert rather than a redo
stack.
<Callout type="warning">
<Warning>
Sending a new prompt while an undo is staged commits the revert. The hidden message range is removed from the active
session history, the currently reverted files are kept, and redo is no longer available.
</Callout>
</Warning>
## Redo
@@ -103,6 +103,6 @@ are not included.
Review the staged file summary and your Git diff before continuing. Commit or back up important work independently before
using undo on a dirty worktree.
<Callout type="note">
<Note>
`/undo` and `/redo` are interactive TUI commands. The non-interactive `run` command does not provide them.
</Callout>
</Note>
@@ -1,4 +1,4 @@
{/* Generated by packages/www/script/generate-theme-tokens.ts. Do not edit. */}
{/* Generated by packages/docs/script/generate-theme-tokens.ts. Do not edit. */}
```json title="my-theme.json"
{
@@ -3,6 +3,8 @@ title: "Themes"
description: "Choose a built-in TUI theme or create a custom color scheme."
---
import ThemeTokens from "/snippets/generated/theme-tokens.mdx"
OpenCode includes built-in light and dark themes and can load custom themes
from your global configuration or a project directory. The default theme is
`opencode`.
@@ -39,10 +41,10 @@ path under `$XDG_CONFIG_HOME`:
}
```
<Callout type="note">
<Note>
Theme selection applies to the full-screen TUI. Direct interactive runs use colors derived from the terminal palette
and honor only the color mode.
</Callout>
</Note>
## Built-in themes
@@ -88,11 +90,11 @@ supported.
V2 themes organize colors into hue scales and semantic tokens. Set `version`
to `2` and define at least one of `light` or `dark`:
<Callout type="warning">
<Warning>
Native V2 custom theme files are not loaded directly by the current beta. Existing custom files use the V1 format and
are migrated to these tokens at runtime. This reference tracks the native V2 schema while direct file loading is
completed.
</Callout>
</Warning>
By default, a theme inherits OpenCode's complete theme, so you only need to
define overrides. Set `mergeMode` to `true` to inherit one mode from the other
@@ -3,10 +3,10 @@ title: "Troubleshooting"
description: "Diagnose OpenCode startup, server, and session issues."
---
<Callout type="tip">
<Tip>
You can ask OpenCode to debug itself. Describe the problem and ask it to use this troubleshooting page; it can read
the steps below, inspect its service and logs, and help identify the issue.
</Callout>
</Tip>
OpenCode runs as two processes: the TUI is a client, while a background server owns sessions, plugins, permissions, and
other application state. Start by determining whether an issue is in the client, the shared server, or a specific project.
@@ -44,10 +44,10 @@ opencode2 service stop
opencode2 service start
```
<Callout type="note">
<Note>
OpenCode normally discovers or starts the shared background service automatically. The service commands are only
needed when diagnosing its lifecycle.
</Callout>
</Note>
## Run an isolated session
@@ -69,9 +69,9 @@ See the [API reference](/api) for all endpoints and operation IDs.
Pass a JSON request body with `--data` or `-d`, and add headers with `--header` or `-H`.
<Callout type="warning">
<Warning>
Running `opencode2 api` may start the background service when no compatible healthy service is available.
</Callout>
</Warning>
## Read logs
@@ -124,10 +124,10 @@ The database normally lives at:
`OPENCODE_DB` can override the database location.
<Callout type="warning">
<Warning>
Do not delete or edit service files or the database while troubleshooting. Use the service commands to manage the
daemon, and make a backup before inspecting persistent data with external tools.
</Callout>
</Warning>
## Explicit servers

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