mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-26 20:56:14 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
129694bf8a | ||
|
|
8fc9325f69 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/ai": patch
|
||||
---
|
||||
|
||||
Report OpenAI prompt cache write tokens in normalized usage.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/cli": patch
|
||||
---
|
||||
|
||||
Expose a TUI plugin slot at the top of the session view.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/cli": patch
|
||||
---
|
||||
|
||||
Expose a TUI plugin slot above the session composer.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/ai": patch
|
||||
---
|
||||
|
||||
Improve Anthropic and Bedrock prompt reuse with layered cache breakpoints that roll through long tool loops.
|
||||
@@ -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 }}
|
||||
@@ -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,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.
|
||||
|
||||
+1
-1
@@ -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
@@ -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
@@ -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:"
|
||||
},
|
||||
|
||||
@@ -55,9 +55,6 @@ const OpenResponsesOutputText = Schema.Struct({
|
||||
text: Schema.String,
|
||||
})
|
||||
|
||||
export const MessagePhase = Schema.Literals(["commentary", "final_answer"])
|
||||
type MessagePhase = Schema.Schema.Type<typeof MessagePhase>
|
||||
|
||||
const OpenResponsesReasoningSummaryText = Schema.Struct({
|
||||
type: Schema.tag("summary_text"),
|
||||
text: Schema.String,
|
||||
@@ -89,14 +86,10 @@ const OpenResponsesFunctionCallOutput = Schema.Union([
|
||||
Schema.Array(OpenResponsesFunctionCallOutputContent),
|
||||
])
|
||||
|
||||
export const InputItem = Schema.Union([
|
||||
const OpenResponsesInputItem = Schema.Union([
|
||||
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
|
||||
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenResponsesInputContent) }),
|
||||
Schema.Struct({
|
||||
role: Schema.tag("assistant"),
|
||||
content: Schema.Array(OpenResponsesOutputText),
|
||||
phase: Schema.optionalKey(MessagePhase),
|
||||
}),
|
||||
Schema.Struct({ role: Schema.tag("assistant"), content: Schema.Array(OpenResponsesOutputText) }),
|
||||
OpenResponsesReasoningItem,
|
||||
OpenResponsesItemReference,
|
||||
Schema.Struct({
|
||||
@@ -111,14 +104,7 @@ export const InputItem = Schema.Union([
|
||||
output: OpenResponsesFunctionCallOutput,
|
||||
}),
|
||||
])
|
||||
type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
|
||||
type LoweredInputItem =
|
||||
| OpenResponsesInputItem
|
||||
| {
|
||||
readonly role: "assistant"
|
||||
readonly content: ReadonlyArray<{ readonly type: "output_text"; readonly text: string }>
|
||||
readonly phase?: MessagePhase | null
|
||||
}
|
||||
type OpenResponsesInputItem = Schema.Schema.Type<typeof OpenResponsesInputItem>
|
||||
|
||||
// Mutable counterpart of the schema reasoning item so `lowerMessages` can fold
|
||||
// multiple streamed summary parts into the same item before flushing.
|
||||
@@ -149,7 +135,7 @@ export const ToolChoice = Schema.Union([
|
||||
// transports in sync without a destructure-and-strip dance.
|
||||
export const coreFields = {
|
||||
model: Schema.String,
|
||||
input: Schema.Array(InputItem),
|
||||
input: Schema.Array(OpenResponsesInputItem),
|
||||
instructions: Schema.optional(Schema.String),
|
||||
tools: optionalArray(Tool),
|
||||
tool_choice: Schema.optional(ToolChoice),
|
||||
@@ -220,7 +206,6 @@ export const Event = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
type: Schema.String,
|
||||
delta: Schema.optional(Schema.String),
|
||||
text: Schema.optional(Schema.String),
|
||||
item_id: Schema.optional(Schema.String),
|
||||
summary_index: Schema.optional(Schema.Number),
|
||||
item: Schema.optional(StreamItem),
|
||||
@@ -253,7 +238,6 @@ export interface Extension {
|
||||
readonly media: ProviderShared.ValidatedMedia
|
||||
readonly request: LLMRequest
|
||||
}) => MediaInput | undefined
|
||||
readonly messagePhase?: (value: unknown) => MessagePhase | null | undefined
|
||||
}
|
||||
|
||||
const BASE: Extension = { id: ADAPTER, name: NAME }
|
||||
@@ -265,9 +249,6 @@ export interface ParserState {
|
||||
readonly tools: ToolStream.State<string>
|
||||
readonly hasFunctionCall: boolean
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly messageItems: ReadonlySet<string>
|
||||
readonly messagePhase: (value: unknown) => MessagePhase | null | undefined
|
||||
readonly messagePhases: Readonly<Record<string, MessagePhase | null>>
|
||||
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
|
||||
readonly store: boolean | undefined
|
||||
}
|
||||
@@ -397,9 +378,9 @@ const lowerToolResultOutput = Effect.fn("OpenResponses.lowerToolResultOutput")(f
|
||||
})
|
||||
|
||||
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (request: LLMRequest, extension: Extension) {
|
||||
const system: LoweredInputItem[] =
|
||||
const system: OpenResponsesInputItem[] =
|
||||
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
|
||||
const input: LoweredInputItem[] = [...system]
|
||||
const input: OpenResponsesInputItem[] = [...system]
|
||||
const store = OpenResponsesOptions.resolve(request).store
|
||||
const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
|
||||
|
||||
@@ -431,27 +412,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
const hostedToolReferences = new Set<string>()
|
||||
const flushText = () => {
|
||||
if (content.length === 0) return
|
||||
const groups = content.reduce<Array<{ phase: MessagePhase | null | undefined; parts: TextPart[] }>>(
|
||||
(groups, part) => {
|
||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||
const phase =
|
||||
ProviderShared.isRecord(metadata)
|
||||
? messagePhase(metadata.phase, extension)
|
||||
: undefined
|
||||
const group = groups.at(-1)
|
||||
if (group && group.phase === phase) group.parts.push(part)
|
||||
else groups.push({ phase, parts: [part] })
|
||||
return groups
|
||||
},
|
||||
[],
|
||||
)
|
||||
input.push(
|
||||
...groups.map((group) => ({
|
||||
role: "assistant" as const,
|
||||
content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
|
||||
...(group.phase === undefined ? {} : { phase: group.phase }),
|
||||
})),
|
||||
)
|
||||
input.push({ role: "assistant", content: content.map((part) => ({ type: "output_text", text: part.text })) })
|
||||
content.splice(0, content.length)
|
||||
}
|
||||
for (const part of message.content) {
|
||||
@@ -552,9 +513,9 @@ const lowerOptions = (request: LLMRequest) => {
|
||||
}
|
||||
}
|
||||
|
||||
export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWithExtension")(function* (
|
||||
export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (
|
||||
request: LLMRequest,
|
||||
extension: Extension,
|
||||
extension: Extension = BASE,
|
||||
) {
|
||||
const generation = request.generation
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
@@ -580,12 +541,6 @@ export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWith
|
||||
}
|
||||
})
|
||||
|
||||
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenResponsesBody))
|
||||
|
||||
export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (request: LLMRequest) {
|
||||
return yield* decodeBody(yield* fromRequestWithExtension(request, BASE))
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Stream Parsing
|
||||
// =============================================================================
|
||||
@@ -640,30 +595,24 @@ const NO_EVENTS: StepResult["1"] = []
|
||||
const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "response.failed"])
|
||||
export const terminal = (event: Event) => TERMINAL_TYPES.has(event.type)
|
||||
|
||||
const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepResult => {
|
||||
const onOutputTextDelta = (state: ParserState, event: Event): StepResult => {
|
||||
if (!event.delta) return [state, NO_EVENTS]
|
||||
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(state.lifecycle, events, event.item_id ?? "text-0", 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 onOutputTextDone = (state: ParserState, event: Event): StepResult => {
|
||||
const events: LLMEvent[] = []
|
||||
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, id) }, events]
|
||||
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, event.item_id ?? "text-0") }, events]
|
||||
}
|
||||
|
||||
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
|
||||
export const onReasoningDelta = (state: ParserState, event: Event): StepResult => {
|
||||
if (!event.delta) return [state, NO_EVENTS]
|
||||
const events: LLMEvent[] = []
|
||||
const itemID = event.item_id ?? "reasoning-0"
|
||||
const id =
|
||||
event.summary_index !== undefined || state.reasoningItems[itemID] ? `${itemID}:${event.summary_index ?? 0}` : itemID
|
||||
return [
|
||||
@@ -694,18 +643,6 @@ const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }
|
||||
// best-effort, not guaranteed.
|
||||
const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||
const item = event.item
|
||||
if (item?.type === "message" && item.id)
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
messageItems: new Set([...state.messageItems, item.id]),
|
||||
messagePhases: (() => {
|
||||
const phase = state.messagePhase(item.phase)
|
||||
return phase === undefined ? state.messagePhases : { ...state.messagePhases, [item.id]: phase }
|
||||
})(),
|
||||
},
|
||||
NO_EVENTS,
|
||||
]
|
||||
if (item && isReasoningItem(item)) {
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
@@ -862,28 +799,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
const item = event.item
|
||||
if (!item) return [state, NO_EVENTS] satisfies StepResult
|
||||
|
||||
if (item.type === "message" && item.id) {
|
||||
const itemPhase = state.messagePhase(item.phase)
|
||||
const phase = itemPhase === undefined ? state.messagePhases[item.id] : itemPhase
|
||||
const events: LLMEvent[] = []
|
||||
const messageItems = new Set(state.messageItems)
|
||||
messageItems.delete(item.id)
|
||||
const { [item.id]: _phase, ...messagePhases } = state.messagePhases
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.textEnd(
|
||||
state.lifecycle,
|
||||
events,
|
||||
item.id,
|
||||
phase === undefined ? undefined : providerMetadata(state, { phase }),
|
||||
),
|
||||
messageItems,
|
||||
messagePhases,
|
||||
},
|
||||
events,
|
||||
] satisfies StepResult
|
||||
}
|
||||
if (item.type === "message" && item.id) return onOutputTextDone(state, { ...event, item_id: item.id })
|
||||
|
||||
if (item.type === "function_call") {
|
||||
if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
|
||||
@@ -983,41 +899,19 @@ const providerError = (state: ParserState, event: Event, fallback: string) => {
|
||||
}
|
||||
|
||||
export const step = (state: ParserState, event: Event) => {
|
||||
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),
|
||||
)
|
||||
}
|
||||
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`)
|
||||
return Effect.succeed(onReasoningDelta(state, event, event.item_id))
|
||||
}
|
||||
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`)
|
||||
if (event.type === "response.output_text.delta") return Effect.succeed(onOutputTextDelta(state, event))
|
||||
if (event.type === "response.output_text.done") return Effect.succeed(onOutputTextDone(state, event))
|
||||
if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_summary_text.delta")
|
||||
return Effect.succeed(onReasoningDelta(state, event))
|
||||
if (event.type === "response.reasoning.done" || event.type === "response.reasoning_summary_text.done")
|
||||
return Effect.succeed(onReasoningDone(state, event))
|
||||
}
|
||||
if (event.type === "response.reasoning_summary_part.added")
|
||||
return event.item_id
|
||||
? Effect.succeed(onReasoningSummaryPartAdded(state, event))
|
||||
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
return Effect.succeed(onReasoningSummaryPartAdded(state, event))
|
||||
if (event.type === "response.reasoning_summary_part.done")
|
||||
return event.item_id
|
||||
? Effect.succeed(onReasoningSummaryPartDone(state, event))
|
||||
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
if (event.type === "response.output_item.added") {
|
||||
if (event.item?.type === "message" && !event.item.id)
|
||||
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
|
||||
return Effect.succeed(onOutputItemAdded(state, event))
|
||||
}
|
||||
return Effect.succeed(onReasoningSummaryPartDone(state, event))
|
||||
if (event.type === "response.output_item.added") return Effect.succeed(onOutputItemAdded(state, event))
|
||||
if (event.type === "response.function_call_arguments.delta") return onFunctionCallArgumentsDelta(state, event)
|
||||
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.output_item.done") return onOutputItemDone(state, event)
|
||||
if (event.type === "response.completed" || event.type === "response.incomplete")
|
||||
return Effect.succeed(onResponseFinish(state, event))
|
||||
if (event.type === "response.failed") return providerError(state, event, `${state.name} response failed`)
|
||||
@@ -1039,18 +933,10 @@ export const initial = (request: LLMRequest, extension: Extension = BASE): Parse
|
||||
hasFunctionCall: false,
|
||||
tools: ToolStream.empty<string>(),
|
||||
lifecycle: Lifecycle.initial(),
|
||||
messageItems: new Set<string>(),
|
||||
messagePhase: (value) => messagePhase(value, extension),
|
||||
messagePhases: {},
|
||||
reasoningItems: {},
|
||||
store: OpenResponsesOptions.resolve(request).store,
|
||||
})
|
||||
|
||||
const messagePhase = (value: unknown, extension: Extension): MessagePhase | null | undefined => {
|
||||
if (value === "commentary" || value === "final_answer") return value
|
||||
return extension.messagePhase?.(value)
|
||||
}
|
||||
|
||||
export const protocol = Protocol.make({
|
||||
id: ADAPTER,
|
||||
body: {
|
||||
|
||||
@@ -35,18 +35,8 @@ const OpenAIResponsesToolChoice = Schema.Union([
|
||||
Schema.Struct({ type: Schema.tag("image_generation") }),
|
||||
])
|
||||
|
||||
const OpenAIResponsesInputItem = Schema.Union([
|
||||
Schema.Struct({
|
||||
role: Schema.tag("assistant"),
|
||||
content: Schema.Array(Schema.Struct({ type: Schema.tag("output_text"), text: Schema.String })),
|
||||
phase: Schema.optionalKey(Schema.NullOr(OpenResponses.MessagePhase)),
|
||||
}),
|
||||
OpenResponses.InputItem,
|
||||
])
|
||||
|
||||
const OpenAIResponsesCoreFields = {
|
||||
...OpenResponses.coreFields,
|
||||
input: Schema.Array(OpenAIResponsesInputItem),
|
||||
tools: optionalArray(OpenAIResponsesTools),
|
||||
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
|
||||
}
|
||||
@@ -70,7 +60,6 @@ const encodeWebSocketMessage = Schema.encodeSync(Schema.fromJsonString(OpenAIRes
|
||||
const extension = {
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
messagePhase: (value: unknown) => (value === null ? null : undefined),
|
||||
lowerMedia: ({ part, media, request }) => {
|
||||
if (request.model.provider !== "xai" || media.mime !== "application/pdf") return undefined
|
||||
return {
|
||||
@@ -113,7 +102,7 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tool
|
||||
})
|
||||
|
||||
const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) {
|
||||
const body = yield* OpenResponses.fromRequestWithExtension(
|
||||
const body = yield* OpenResponses.fromRequest(
|
||||
LLMRequest.update(request, { tools: [], toolChoice: undefined }),
|
||||
extension,
|
||||
)
|
||||
@@ -219,13 +208,9 @@ const onHostedToolDone = Effect.fn("OpenAIResponses.onHostedToolDone")(function*
|
||||
|
||||
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
|
||||
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`)
|
||||
return Effect.succeed(OpenResponses.onReasoningDelta(state, event))
|
||||
if (event.type === "response.reasoning_text.done" || event.type === "response.reasoning_summary.done")
|
||||
return event.item_id
|
||||
? Effect.succeed(OpenResponses.onReasoningDone(state, event))
|
||||
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
|
||||
return Effect.succeed(OpenResponses.onReasoningDone(state, event))
|
||||
if (event.type === "response.output_item.done" && event.item && isHostedToolItem(event.item))
|
||||
return onHostedToolDone(state, event.item)
|
||||
return OpenResponses.step(state, event)
|
||||
|
||||
@@ -123,7 +123,6 @@ export const mergeGenerationOptions = (...items: ReadonlyArray<GenerationOptions
|
||||
|
||||
export class ModelLimits extends Schema.Class<ModelLimits>("LLM.ModelLimits")({
|
||||
context: Schema.optional(Schema.Number),
|
||||
input: Schema.optional(Schema.Number),
|
||||
output: Schema.optional(Schema.Number),
|
||||
}) {}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMEvent, Message } from "../../src"
|
||||
import { LLM, LLMEvent } from "../../src"
|
||||
import { configure } from "../../src/providers/openai-compatible-responses"
|
||||
import { OpenAI } from "../../src/providers"
|
||||
import { OpenResponses } from "../../src/protocols/open-responses"
|
||||
@@ -70,31 +70,6 @@ describe("Open Responses-compatible route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits OpenAI-only nullable phases from the Open Responses baseline", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://responses.example.test/v1",
|
||||
}).model("example-model")
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant({
|
||||
type: "text",
|
||||
text: "Unclassified.",
|
||||
providerMetadata: { openresponses: { phase: null } },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
input: [{ role: "assistant", content: [{ type: "output_text", text: "Unclassified." }] }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reads standard options from the Open Responses namespace", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
|
||||
@@ -882,121 +882,6 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves and replays assistant message phases", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "message", id: "msg_commentary" },
|
||||
},
|
||||
{ type: "response.output_text.delta", item_id: "msg_commentary", delta: "Checking." },
|
||||
{ type: "response.output_text.done", item_id: "msg_commentary" },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "message", id: "msg_commentary", phase: "commentary" },
|
||||
},
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "message", id: "msg_final", phase: "final_answer" },
|
||||
},
|
||||
{ type: "response.output_text.done", item_id: "msg_final", text: "Finished." },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "message", id: "msg_final", phase: "final_answer" },
|
||||
},
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_null", phase: null } },
|
||||
{ type: "response.output_text.delta", item_id: "msg_null", delta: "Unclassified." },
|
||||
{ type: "response.output_item.done", item: { type: "message", id: "msg_null", phase: null } },
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.message.content).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: "Checking.",
|
||||
providerMetadata: { openai: { phase: "commentary" } },
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Finished.",
|
||||
providerMetadata: { openai: { phase: "final_answer" } },
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Unclassified.",
|
||||
providerMetadata: { openai: { phase: null } },
|
||||
},
|
||||
])
|
||||
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
LLM.request({ model, messages: [response.message] }),
|
||||
)
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Checking." }],
|
||||
phase: "commentary",
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Finished." }],
|
||||
phase: "final_answer",
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Unclassified." }],
|
||||
phase: null,
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects output text events without the spec-required item id", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_text.delta", delta: "orphaned" },
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.message).toContain("response.output_text.delta is missing item_id")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects reasoning events without the spec-required item id", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = [
|
||||
{ type: "response.reasoning_summary_part.added", summary_index: 0 },
|
||||
{ type: "response.reasoning_summary_part.done", summary_index: 0 },
|
||||
{ type: "response.reasoning_text.done" },
|
||||
]
|
||||
|
||||
for (const event of events) {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(sseEvents(event, { type: "response.completed", response: { id: "resp_1" } })),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.message).toContain(`${event.type} is missing item_id`)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps incomplete response reasons", () =>
|
||||
Effect.gen(function* () {
|
||||
const generate = (incompleteDetails: object) =>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -56,6 +56,16 @@
|
||||
"@lydell/node-pty-linux-x64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-win32-arm64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-win32-x64": "1.2.0-beta.12",
|
||||
"@ff-labs/fff-bin-darwin-arm64": "0.10.1",
|
||||
"@ff-labs/fff-bin-linux-arm64-gnu": "0.10.1",
|
||||
"@ff-labs/fff-bin-linux-x64-gnu": "0.10.1",
|
||||
"@ff-labs/fff-bin-win32-arm64": "0.10.1",
|
||||
"@ff-labs/fff-bin-win32-x64": "0.10.1",
|
||||
"@yuuang/ffi-rs-darwin-arm64": "1.3.2",
|
||||
"@yuuang/ffi-rs-linux-arm64-gnu": "1.3.2",
|
||||
"@yuuang/ffi-rs-linux-x64-gnu": "1.3.2",
|
||||
"@yuuang/ffi-rs-win32-arm64-msvc": "1.3.2",
|
||||
"@yuuang/ffi-rs-win32-x64-msvc": "1.3.2",
|
||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
|
||||
"@parcel/watcher-linux-x64-glibc": "2.5.1",
|
||||
|
||||
@@ -37,6 +37,8 @@ export async function collectNodeAssets(target: NodeTarget) {
|
||||
...(target.platform === "linux" ? { libc: "glibc" as const } : {}),
|
||||
}),
|
||||
{ key: target.parcelWatcherAsset, source: fileURLToPath(import.meta.resolve(target.parcelWatcherPackage)) },
|
||||
{ key: target.fffAsset, source: fileURLToPath(import.meta.resolve(target.fffPackage)) },
|
||||
{ key: target.fffFfiAsset, source: fileURLToPath(import.meta.resolve(target.fffFfiPackage)) },
|
||||
{
|
||||
key: photonWasmAsset,
|
||||
source: fileURLToPath(import.meta.resolve(photonWasmAsset)),
|
||||
|
||||
@@ -11,6 +11,8 @@ export function nodeTarget(platform: string, arch: string) {
|
||||
const targetArch = arch as "arm64" | "x64"
|
||||
const nodePtyPackage = `@lydell/node-pty-${targetPlatform}-${targetArch}`
|
||||
const parcelWatcherPackage = `@parcel/watcher-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-glibc" : ""}`
|
||||
const fffPackage = `@ff-labs/fff-bin-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : ""}`
|
||||
const fffFfiPackage = `@yuuang/ffi-rs-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : targetPlatform === "win32" ? "-msvc" : ""}`
|
||||
|
||||
return {
|
||||
platform: targetPlatform,
|
||||
@@ -19,6 +21,10 @@ export function nodeTarget(platform: string, arch: string) {
|
||||
nodePtyEntryAsset: `${nodePtyPackage}/lib/index.js`,
|
||||
parcelWatcherPackage,
|
||||
parcelWatcherAsset: `${parcelWatcherPackage}/watcher.node`,
|
||||
fffPackage,
|
||||
fffAsset: `${fffPackage}/${targetPlatform === "darwin" ? "libfff_c.dylib" : targetPlatform === "win32" ? "fff_c.dll" : "libfff_c.so"}`,
|
||||
fffFfiPackage,
|
||||
fffFfiAsset: `${fffFfiPackage}/ffi-rs.${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : targetPlatform === "win32" ? "-msvc" : ""}.node`,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,30 @@ function runtimeRequirePlugin(): Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
function fffNodePlugin(): Plugin {
|
||||
return {
|
||||
name: "opencode:fff-node",
|
||||
enforce: "pre",
|
||||
transform(code, id) {
|
||||
const normalized = id.replaceAll("\\", "/")
|
||||
if (normalized.endsWith("/ffi-rs/index.js")) {
|
||||
const start = code.indexOf("if (!nativeBinding) {")
|
||||
if (start === -1) this.error("Failed to rewrite ffi-rs native binding loader")
|
||||
return `const nativeBinding = globalThis.__OPENCODE_FFF_FFI
|
||||
const loadError = undefined
|
||||
${code.slice(start)}`
|
||||
}
|
||||
if (!normalized.endsWith("/fff-node/dist/src/binary.js")) return
|
||||
const transformed = code.replace(
|
||||
"export function findBinary() {",
|
||||
"export function findBinary() { if (process.env.FFF_BINARY_PATH) return process.env.FFF_BINARY_PATH;",
|
||||
)
|
||||
if (transformed === code) this.error("Failed to rewrite FFF binary loader")
|
||||
return transformed
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const resolve = {
|
||||
alias: [
|
||||
{ find: /^solid-js\/store$/, replacement: "solid-js/store/dist/store.js" },
|
||||
@@ -165,6 +189,9 @@ process.env.OTUI_ASSET_ROOT = __ocAssetRoot
|
||||
process.env.OPENCODE_NODE_PTY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.nodePtyEntryAsset)})
|
||||
process.env.OPENCODE_PARCEL_WATCHER_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.parcelWatcherAsset)})
|
||||
process.env.OPENCODE_PHOTON_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(photonWasmAsset)})
|
||||
process.env.FFF_BINARY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffAsset)})
|
||||
process.env.OPENCODE_FFF_FFI_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffFfiAsset)})
|
||||
globalThis.__OPENCODE_FFF_FFI = require(process.env.OPENCODE_FFF_FFI_PATH)
|
||||
globalThis.__OPENCODE_PHOTON_WASM_PATH = process.env.OPENCODE_PHOTON_WASM_PATH
|
||||
if (process.platform === "linux") process.env.OPENTUI_LIBC = "glibc"`
|
||||
}
|
||||
@@ -183,6 +210,7 @@ export function mainConfig(input: NodeBuildInput): UserConfig {
|
||||
plugins: [
|
||||
rawTextPlugin(),
|
||||
runtimeRequirePlugin(),
|
||||
fffNodePlugin(),
|
||||
solid({
|
||||
solid: {
|
||||
generate: "universal",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -98,6 +98,8 @@ export type SessionMessageShell = {
|
||||
output?: { output: string; cursor: number; size: number; truncated: boolean }
|
||||
}
|
||||
|
||||
export type SessionMessageAssistantText = { type: "text"; text: string }
|
||||
|
||||
export type SessionMessageProviderState = { [x: string]: JsonValue }
|
||||
|
||||
export type SessionMessageToolStateStreaming = { status: "streaming"; input: string }
|
||||
@@ -155,6 +157,8 @@ export type ShellInfo = {
|
||||
time: { started: number; completed?: number }
|
||||
}
|
||||
|
||||
export type SessionMessageProviderState3 = { [x: string]: any }
|
||||
|
||||
export type SessionMessageProviderState4 = { [x: string]: any }
|
||||
|
||||
export type SessionMessageProviderState5 = { [x: string]: any }
|
||||
@@ -163,10 +167,6 @@ export type SessionMessageProviderState6 = { [x: string]: any }
|
||||
|
||||
export type SessionMessageProviderState7 = { [x: string]: any }
|
||||
|
||||
export type SessionMessageProviderState8 = { [x: string]: any }
|
||||
|
||||
export type SessionMessageProviderState9 = { [x: string]: any }
|
||||
|
||||
export type EventLogSynced = { type: "log.synced"; aggregateID: string; seq?: number }
|
||||
|
||||
export type ModelReasoningField = "reasoning" | "reasoning_content" | "reasoning_text" | (string & {})
|
||||
@@ -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 }
|
||||
@@ -737,6 +733,16 @@ export type SessionTextStarted = {
|
||||
data: { sessionID: string; assistantMessageID: string; ordinal: number }
|
||||
}
|
||||
|
||||
export type SessionTextEnded = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.text.ended"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; assistantMessageID: string; ordinal: number; text: string }
|
||||
}
|
||||
|
||||
export type SessionToolInputStarted = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -1062,15 +1068,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
|
||||
@@ -1252,8 +1249,6 @@ export type SessionPendingSynthetic = {
|
||||
delivery: "steer" | "queue"
|
||||
}
|
||||
|
||||
export type SessionMessageAssistantText = { type: "text"; text: string; state?: SessionMessageProviderState }
|
||||
|
||||
export type SessionMessageAssistantReasoning = {
|
||||
type: "reasoning"
|
||||
text: string
|
||||
@@ -1364,22 +1359,6 @@ export type ShellCreated = {
|
||||
data: { info: ShellInfo }
|
||||
}
|
||||
|
||||
export type SessionTextEnded = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.text.ended"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
ordinal: number
|
||||
text: string
|
||||
state?: SessionMessageProviderState4
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionReasoningStarted = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -1387,7 +1366,7 @@ export type SessionReasoningStarted = {
|
||||
type: "session.reasoning.started"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; assistantMessageID: string; ordinal: number; state?: SessionMessageProviderState5 }
|
||||
data: { sessionID: string; assistantMessageID: string; ordinal: number; state?: SessionMessageProviderState3 }
|
||||
}
|
||||
|
||||
export type SessionReasoningEnded = {
|
||||
@@ -1402,7 +1381,7 @@ export type SessionReasoningEnded = {
|
||||
assistantMessageID: string
|
||||
ordinal: number
|
||||
text: string
|
||||
state?: SessionMessageProviderState6
|
||||
state?: SessionMessageProviderState4
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1419,7 +1398,7 @@ export type SessionToolCalled = {
|
||||
callID: string
|
||||
input: { [x: string]: any }
|
||||
executed: boolean
|
||||
state?: SessionMessageProviderState7
|
||||
state?: SessionMessageProviderState5
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1874,7 +1853,7 @@ export type SessionToolSuccess = {
|
||||
content: [LLMToolContent, ...Array<LLMToolContent>]
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
executed: boolean
|
||||
resultState?: SessionMessageProviderState8
|
||||
resultState?: SessionMessageProviderState6
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1893,7 +1872,7 @@ export type SessionToolFailed = {
|
||||
content?: [LLMToolContent, ...Array<LLMToolContent>]
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
executed: boolean
|
||||
resultState?: SessionMessageProviderState9
|
||||
resultState?: SessionMessageProviderState7
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2368,7 +2347,6 @@ export type V2Event =
|
||||
| FormCreated
|
||||
| FormReplied
|
||||
| FormCancelled
|
||||
| WebsearchUpdated
|
||||
| SessionStatus2
|
||||
| SessionIdle
|
||||
| TuiPromptAppend
|
||||
@@ -4972,27 +4950,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> }
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ export type {
|
||||
PluginApi,
|
||||
ProviderApi,
|
||||
ReferenceApi,
|
||||
WebSearchApi,
|
||||
SessionApi,
|
||||
SkillApi,
|
||||
} from "./api.js"
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -342,6 +342,7 @@ export type DiscoveryPlan = {
|
||||
|
||||
export type SearchEntry = {
|
||||
readonly description: ToolDescription
|
||||
readonly namespace: string
|
||||
readonly searchText: string
|
||||
}
|
||||
|
||||
@@ -372,11 +373,7 @@ const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Tool => ({
|
||||
const scoped =
|
||||
request.namespace === undefined
|
||||
? searchIndex
|
||||
: searchIndex.filter(
|
||||
(entry) =>
|
||||
entry.description.path === request.namespace ||
|
||||
entry.description.path.startsWith(`${request.namespace}.`),
|
||||
)
|
||||
: searchIndex.filter((entry) => entry.namespace === request.namespace)
|
||||
const trimmed = query.trim()
|
||||
const pathQuery = trimmed.startsWith("tools.") ? trimmed.slice("tools.".length) : trimmed
|
||||
const exact =
|
||||
@@ -431,6 +428,7 @@ export const searchSignature = (() => {
|
||||
|
||||
const toSearchEntry = <R>(path: string, tool: Tool<R>, description: ToolDescription): SearchEntry => ({
|
||||
description,
|
||||
namespace: path.split(".", 1)[0]!,
|
||||
searchText: [
|
||||
path,
|
||||
tool.description,
|
||||
|
||||
@@ -54,27 +54,6 @@ describe("dotted tool names", () => {
|
||||
expect(flat.catalog()[0]?.path).toBe("issues.list")
|
||||
expect(await value(flat, `return await tools.issues.list({})`)).toBe("flat")
|
||||
})
|
||||
|
||||
test("search scopes to a nested namespace subtree", async () => {
|
||||
const nested = CodeMode.make({
|
||||
tools: {
|
||||
slack: {
|
||||
admin: echo("Admin", "admin"),
|
||||
"admin.invite": echo("Invite", "invite"),
|
||||
"admin.users.list": echo("List users", "users"),
|
||||
"administrator.list": echo("List administrators", "administrators"),
|
||||
read: echo("Read Slack", "read"),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const result = await value(nested, `return search({ query: "", namespace: "slack.admin" })`)
|
||||
expect((result as { items: Array<{ path: string }> }).items.map((item) => item.path)).toEqual([
|
||||
"tools.slack.admin",
|
||||
"tools.slack.admin.invite",
|
||||
"tools.slack.admin.users.list",
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("callable namespaces", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@ff-labs/fff-bun": "0.10.1",
|
||||
"@ff-labs/fff-node": "0.10.1",
|
||||
"@opencode-ai/codemode": "workspace:*",
|
||||
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
||||
"@opencode-ai/effect-sqlite-node": "workspace:*",
|
||||
|
||||
@@ -332,7 +332,7 @@ function modelFromLanguage(info: ModelV2.Info, language: LanguageModelV3) {
|
||||
body: projected.body === undefined ? undefined : { ...projected.body },
|
||||
headers: info.headers,
|
||||
},
|
||||
limits: { context: info.limit.context, input: info.limit.input, output: info.limit.output },
|
||||
limits: { context: info.limit.context, output: info.limit.output },
|
||||
providerOptions,
|
||||
},
|
||||
body: {
|
||||
|
||||
@@ -63,6 +63,7 @@ const layer = Layer.effect(
|
||||
if (rule?.resource === "*" && rule.effect === "deny") continue
|
||||
registrations.set(name, registration)
|
||||
}
|
||||
if (registrations.size === 0) return {}
|
||||
const executeRule = rules.findLast((rule) => Wildcard.match("execute", rule.action))
|
||||
if (executeRule?.resource === "*" && executeRule.effect === "deny") return {}
|
||||
return {
|
||||
|
||||
@@ -6,7 +6,9 @@ import { Instructions } from "../instructions/index"
|
||||
import { CodeModeCatalog } from "./catalog"
|
||||
|
||||
// prettier-ignore
|
||||
const prompt = (hasMoreTools: boolean) => `The Code Mode tool catalog below is ${hasMoreTools ? "partial" : "complete"}.${hasMoreTools ? `
|
||||
const prompt = (hasMoreTools: boolean) => `The Code Mode tool catalog below is ${hasMoreTools ? "partial" : "complete"}.
|
||||
|
||||
Inside Code Mode, \`tools\` contains only the tools shown below${hasMoreTools ? " or returned by `search`" : ""}; surrounding top-level agent tools are not available and must not be called from the code.${hasMoreTools ? `
|
||||
|
||||
## Search
|
||||
|
||||
@@ -17,8 +19,7 @@ Use \`search\` to discover exact paths and signatures for additional tools:
|
||||
## Available tools`
|
||||
|
||||
export function render(catalog: CodeModeCatalog.Summary) {
|
||||
if (catalog.total === 0)
|
||||
return "No Code Mode tools are currently available. Later Code Mode catalog updates may add or remove tools. Do not call `execute` unless there is at least one available Code Mode tool."
|
||||
if (catalog.total === 0) return "No tools are currently available."
|
||||
|
||||
const tools = catalog.namespaces.flatMap((namespace) => {
|
||||
const count = namespace.count === 1 ? "1 tool" : `${namespace.count} tools`
|
||||
@@ -37,13 +38,12 @@ ${tools.join("\n")}`
|
||||
}
|
||||
|
||||
export function update(previous: CodeModeCatalog.Summary, current: CodeModeCatalog.Summary) {
|
||||
const replacement = `The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.
|
||||
const full = `The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.
|
||||
|
||||
${render(current)}`
|
||||
if (current.total === 0) return replacement
|
||||
const previousComplete = previous.shown === previous.total
|
||||
const currentComplete = current.shown === current.total
|
||||
if (previousComplete !== currentComplete) return replacement
|
||||
if (previousComplete !== currentComplete) return full
|
||||
|
||||
const diff = Instructions.diffByKey(
|
||||
previous.namespaces.flatMap((namespace) => namespace.entries),
|
||||
@@ -54,7 +54,7 @@ ${render(current)}`
|
||||
const entriesChanged = diff.added.length > 0 || diff.removed.length > 0 || diff.changed.length > 0
|
||||
|
||||
if (!currentComplete) {
|
||||
if (entriesChanged) return replacement
|
||||
if (entriesChanged) return full
|
||||
const namespaces = Instructions.diffByKey(
|
||||
previous.namespaces,
|
||||
current.namespaces,
|
||||
@@ -62,7 +62,7 @@ ${render(current)}`
|
||||
(before, after) => before.count !== after.count,
|
||||
)
|
||||
const changed = namespaces.added.length > 0 || namespaces.removed.length > 0 || namespaces.changed.length > 0
|
||||
if (!changed) return replacement
|
||||
if (!changed) return full
|
||||
|
||||
const parts = ["The Code Mode tool catalog has changed."]
|
||||
if (namespaces.added.length > 0) {
|
||||
@@ -87,11 +87,11 @@ ${render(current)}`
|
||||
)
|
||||
}
|
||||
const delta = parts.join("\n\n")
|
||||
if (delta.length < replacement.length) return delta
|
||||
return replacement
|
||||
if (delta.length < full.length) return delta
|
||||
return full
|
||||
}
|
||||
|
||||
if (!entriesChanged) return replacement
|
||||
if (!entriesChanged) return full
|
||||
const parts = ["The Code Mode tool catalog has changed."]
|
||||
if (diff.added.length > 0) {
|
||||
parts.push(
|
||||
@@ -117,19 +117,19 @@ ${render(current)}`
|
||||
)
|
||||
}
|
||||
const delta = parts.join("\n\n")
|
||||
if (delta.length < replacement.length) return delta
|
||||
return replacement
|
||||
if (delta.length < full.length) return delta
|
||||
return full
|
||||
}
|
||||
|
||||
const key = Instructions.Key.make("core/codemode")
|
||||
const codec = Schema.toCodecJson(CodeModeCatalog.Summary)
|
||||
|
||||
export const make = (entries?: ReadonlyArray<CodeModeCatalog.Entry>): Instructions.Instructions => {
|
||||
const catalog = entries === undefined ? Instructions.removed : CodeModeCatalog.summarize(entries)
|
||||
const catalog = CodeModeCatalog.summarize(entries ?? [])
|
||||
return Instructions.make({
|
||||
key,
|
||||
codec,
|
||||
read: Effect.succeed(catalog),
|
||||
read: Effect.succeed(catalog.total === 0 ? Instructions.removed : catalog),
|
||||
render: {
|
||||
initial: render,
|
||||
changed: update,
|
||||
|
||||
@@ -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 }),
|
||||
)
|
||||
}),
|
||||
})
|
||||
@@ -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,
|
||||
}) {}
|
||||
@@ -1,71 +1,51 @@
|
||||
import {
|
||||
FileFinder,
|
||||
type DirItem,
|
||||
type DirSearchResult,
|
||||
type FileItem,
|
||||
type GrepCursor,
|
||||
type GrepMatch,
|
||||
type GrepResult,
|
||||
type InitOptions,
|
||||
type MixedItem,
|
||||
type MixedSearchResult,
|
||||
type SearchResult,
|
||||
} from "@ff-labs/fff-node"
|
||||
|
||||
export type Result<T> = { ok: true; value: T } | { ok: false; error: string }
|
||||
|
||||
export interface Init {
|
||||
basePath: string
|
||||
frecencyDbPath?: string
|
||||
historyDbPath?: string
|
||||
useUnsafeNoLock?: boolean
|
||||
disableMmapCache?: boolean
|
||||
disableContentIndexing?: boolean
|
||||
disableWatch?: boolean
|
||||
aiMode?: boolean
|
||||
logFilePath?: string
|
||||
logLevel?: "trace" | "debug" | "info" | "warn" | "error"
|
||||
enableFsRootScanning?: boolean
|
||||
enableHomeDirScanning?: boolean
|
||||
}
|
||||
|
||||
export interface File {
|
||||
relativePath: string
|
||||
fileName: string
|
||||
modified: number
|
||||
}
|
||||
|
||||
export interface Directory {
|
||||
relativePath: string
|
||||
dirName: string
|
||||
maxAccessFrecency: number
|
||||
}
|
||||
|
||||
export type Mixed = { type: "file"; item: File } | { type: "directory"; item: Directory }
|
||||
export type Init = InitOptions
|
||||
|
||||
export interface Search {
|
||||
items: File[]
|
||||
scores: Array<{ total: number }>
|
||||
items: FileItem[]
|
||||
scores: SearchResult["scores"]
|
||||
totalMatched: number
|
||||
totalFiles: number
|
||||
}
|
||||
|
||||
export interface DirSearch {
|
||||
items: Directory[]
|
||||
scores: Array<{ total: number }>
|
||||
items: DirItem[]
|
||||
scores: DirSearchResult["scores"]
|
||||
totalMatched: number
|
||||
totalDirs: number
|
||||
}
|
||||
|
||||
export interface MixedSearch {
|
||||
items: Mixed[]
|
||||
scores: Array<{ total: number }>
|
||||
items: MixedItem[]
|
||||
scores: MixedSearchResult["scores"]
|
||||
totalMatched: number
|
||||
totalFiles: number
|
||||
totalDirs: number
|
||||
}
|
||||
|
||||
export type Cursor = null
|
||||
|
||||
export interface Hit {
|
||||
relativePath: string
|
||||
fileName: string
|
||||
lineNumber: number
|
||||
byteOffset: number
|
||||
lineContent: string
|
||||
matchRanges: [number, number][]
|
||||
contextBefore?: string[]
|
||||
contextAfter?: string[]
|
||||
}
|
||||
export type File = FileItem
|
||||
export type Directory = DirItem
|
||||
export type Mixed = MixedItem
|
||||
export type Cursor = GrepCursor | null
|
||||
export type Hit = GrepMatch
|
||||
|
||||
export interface Grep {
|
||||
items: Hit[]
|
||||
items: GrepResult["items"]
|
||||
totalMatched: number
|
||||
totalFilesSearched: number
|
||||
totalFiles: number
|
||||
@@ -128,11 +108,29 @@ export interface Picker {
|
||||
}
|
||||
|
||||
export function available() {
|
||||
return false
|
||||
return FileFinder.isAvailable()
|
||||
}
|
||||
|
||||
export function create(_opts: Init): Result<Picker> {
|
||||
return { ok: false, error: "fff unavailable on node runtime" }
|
||||
export function create(opts: Init): Result<Picker> {
|
||||
const made = FileFinder.create(opts)
|
||||
if (!made.ok) return made
|
||||
const pick = made.value
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
destroy: () => pick.destroy(),
|
||||
isScanning: () => pick.isScanning(),
|
||||
waitForScan: (timeoutMs) => pick.waitForScan(timeoutMs),
|
||||
refreshGitStatus: () => pick.refreshGitStatus(),
|
||||
fileSearch: (query, next) => pick.fileSearch(query, next),
|
||||
glob: (pattern, next) => pick.glob(pattern, next),
|
||||
directorySearch: (query, next) => pick.directorySearch(query, next),
|
||||
mixedSearch: (query, next) => pick.mixedSearch(query, next),
|
||||
grep: (query, next) => pick.grep(query, next),
|
||||
trackQuery: (query, file) => pick.trackQuery(query, file),
|
||||
getHistoricalQuery: (offset) => pick.getHistoricalQuery(offset),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export * as Fff from "./fff.node"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -81,7 +81,7 @@ const withDefaults = (model: ModelV2.Info, route: AnyRoute) =>
|
||||
headers: providerHeaders(model),
|
||||
providerOptions: providerOptions(model),
|
||||
http: model.body === undefined ? undefined : { body: model.body },
|
||||
limits: { context: model.limit.context, input: model.limit.input, output: model.limit.output },
|
||||
limits: { context: model.limit.context, output: model.limit.output },
|
||||
})
|
||||
|
||||
const providerHeaders = (model: ModelV2.Info) => {
|
||||
@@ -206,7 +206,7 @@ export const fromCatalogModel = (
|
||||
...nativeCredentialSettings(specifier, credential),
|
||||
headers: resolved.headers,
|
||||
body: resolved.body,
|
||||
limits: { context: resolved.limit.context, input: resolved.limit.input, output: resolved.limit.output },
|
||||
limits: { context: resolved.limit.context, output: resolved.limit.output },
|
||||
}
|
||||
return yield* Effect.try({
|
||||
try: () => {
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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) })
|
||||
}
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -180,12 +180,6 @@ export const OpenAIPlugin = define({
|
||||
})
|
||||
yield* load()
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
const codex = evt.provider.get(ProviderV2.ID.make("openai-codex"))
|
||||
if (codex)
|
||||
for (const model of codex.models.values())
|
||||
evt.model.update(codex.provider.id, model.id, (draft) => {
|
||||
draft.enabled = false
|
||||
})
|
||||
for (const item of evt.provider.list()) {
|
||||
if (!ProviderV2.isAISDK(item.provider.package)) continue
|
||||
if (ProviderV2.packageName(item.provider.package) !== "@ai-sdk/openai") continue
|
||||
@@ -211,8 +205,6 @@ export const OpenAIPlugin = define({
|
||||
draft.enabled = false
|
||||
return
|
||||
}
|
||||
const route = codex?.models.get(draft.id)
|
||||
if (route) draft.limit = { ...route.limit }
|
||||
draft.cost = []
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ Use this guide as the starting point for work involving OpenCode itself. It
|
||||
covers the core concepts needed to configure and customize OpenCode, extend it
|
||||
with plugins, and build integrations with the OpenCode SDK, clients, and API.
|
||||
|
||||
Full documentation is available at <https://v2.opencode.ai/docs>. This overview is
|
||||
Full documentation is available at <https://v2.opencode.ai/>. This overview is
|
||||
only an index of core concepts. Before answering a question about a topic below,
|
||||
fetch the URL named in that section and use the full page as the source of
|
||||
truth. Follow links from that page when the question needs more detail. Fetch
|
||||
@@ -16,7 +16,7 @@ documentation page.
|
||||
Always answer for OpenCode V2 unless the user explicitly asks about V1,
|
||||
legacy OpenCode, or migrating from V1.
|
||||
|
||||
Use only <https://v2.opencode.ai/docs> documentation as the source of truth for V2.
|
||||
Use only <https://v2.opencode.ai/> documentation as the source of truth for V2.
|
||||
Do not use <https://opencode.ai/docs/>, which documents V1, and do not use
|
||||
general web search to resolve a V2 documentation question when the V2 docs or
|
||||
their `llms.txt` index cover it. The schema served from
|
||||
@@ -29,7 +29,7 @@ V1 documentation and syntax may be consulted only when the user explicitly
|
||||
asks about V1 or when needed as migration input. Outputs and recommendations
|
||||
must still use V2 unless the user specifically requests a V1 result.
|
||||
|
||||
## [Configuration](https://v2.opencode.ai/docs/config)
|
||||
## [Configuration](https://v2.opencode.ai/config)
|
||||
|
||||
OpenCode configuration uses JSON or JSONC. Include the published schema so the
|
||||
user's editor can validate fields and provide autocomplete:
|
||||
@@ -60,15 +60,15 @@ linked topic guide as the source of truth, and preserve unrelated settings when
|
||||
editing an existing file. Keep the published `$schema` URL in configuration
|
||||
examples, but do not fetch it to determine the V2 configuration shape.
|
||||
|
||||
See the [full configuration guide](https://v2.opencode.ai/docs/config) for
|
||||
See the [full configuration guide](https://v2.opencode.ai/config) for
|
||||
every field, examples, config locations, and links to dedicated feature guides.
|
||||
|
||||
## [V1 to V2 migration](https://v2.opencode.ai/docs/migrate-v1)
|
||||
## [V1 to V2 migration](https://v2.opencode.ai/migrate-v1)
|
||||
|
||||
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`.
|
||||
[migration guide](https://v2.opencode.ai/migrate-v1) before acting. In
|
||||
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
|
||||
@@ -76,18 +76,18 @@ V2 config uses more ergonomic shapes, but conversion is optional. When the user
|
||||
requests conversion, inspect the complete configuration, preserve behavior and
|
||||
unrelated settings, and apply only the relevant migrations from the guide. For
|
||||
plugin migrations, fetch and follow both the migration guide and the full
|
||||
[plugins guide](https://v2.opencode.ai/docs/build/plugins). If non-API V1
|
||||
[plugins guide](https://v2.opencode.ai/build/plugins). If non-API V1
|
||||
functionality fails in V2, use the `report` skill to file it as a compatibility
|
||||
bug.
|
||||
|
||||
## [Plugins](https://v2.opencode.ai/docs/build/plugins)
|
||||
## [Plugins](https://v2.opencode.ai/build/plugins)
|
||||
|
||||
For questions about creating, configuring, loading, publishing, or migrating
|
||||
plugins, fetch the full [plugins guide](https://v2.opencode.ai/docs/build/plugins)
|
||||
plugins, fetch the full [plugins guide](https://v2.opencode.ai/build/plugins)
|
||||
before answering. This includes questions about the Effect plugin API, hooks,
|
||||
transforms, tools, plugin context capabilities, and package entrypoints.
|
||||
|
||||
## [Service](https://v2.opencode.ai/docs/troubleshooting#check-the-background-service)
|
||||
## [Service](https://v2.opencode.ai/troubleshooting#check-the-background-service)
|
||||
|
||||
OpenCode uses a client-server architecture. Interfaces such as the TUI connect
|
||||
to a background OpenCode service, which owns sessions, configuration, plugins,
|
||||
@@ -106,7 +106,7 @@ Check its status after restarting:
|
||||
opencode2 service status
|
||||
```
|
||||
|
||||
## [API](https://v2.opencode.ai/docs/api)
|
||||
## [API](https://v2.opencode.ai/api)
|
||||
|
||||
OpenCode exposes an HTTP API from its server. The API is described by an
|
||||
OpenAPI document available from the running server at `/openapi.json`.
|
||||
@@ -135,15 +135,15 @@ connected to an explicit server instead of its managed background service, use
|
||||
the same configured server and authentication context rather than constructing
|
||||
an unauthenticated request separately.
|
||||
|
||||
See the [full API reference](https://v2.opencode.ai/docs/api) for available
|
||||
See the [full API reference](https://v2.opencode.ai/api) for available
|
||||
endpoints, parameters, request bodies, and response schemas. The
|
||||
raw [OpenAPI specification](https://v2.opencode.ai/openapi.json) is also
|
||||
available for code generation and other tooling.
|
||||
|
||||
## [Client](https://v2.opencode.ai/docs/build/client)
|
||||
## [Client](https://v2.opencode.ai/build/client)
|
||||
|
||||
For questions about connecting an application to OpenCode over the network,
|
||||
fetch the full [client guide](https://v2.opencode.ai/docs/build/client) before
|
||||
fetch the full [client guide](https://v2.opencode.ai/build/client) before
|
||||
answering.
|
||||
|
||||
`@opencode-ai/client` is the generated TypeScript client for the OpenCode HTTP
|
||||
@@ -154,7 +154,7 @@ exposes typed Effects, Streams, and decoded OpenCode schema values. Its
|
||||
`Service` API can discover, start, stop, and authenticate with the local
|
||||
background service from a Node application.
|
||||
|
||||
## [Troubleshooting](https://v2.opencode.ai/docs/troubleshooting)
|
||||
## [Troubleshooting](https://v2.opencode.ai/troubleshooting)
|
||||
|
||||
OpenCode runs a client and a background server. Start by determining whether a
|
||||
problem belongs to the client, the shared server, or one project.
|
||||
@@ -174,6 +174,6 @@ problem belongs to the client, the shared server, or one project.
|
||||
- Redact API keys, authorization headers, prompts, file contents, and other
|
||||
sensitive data before sharing diagnostics.
|
||||
|
||||
See the [full troubleshooting guide](https://v2.opencode.ai/docs/troubleshooting)
|
||||
See the [full troubleshooting guide](https://v2.opencode.ai/troubleshooting)
|
||||
for service lifecycle commands, API inspection, log locations, explicit server
|
||||
connections, issue-reporting details, and local development paths.
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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
|
||||
@@ -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 } : {}) },
|
||||
}
|
||||
}) ?? []
|
||||
)
|
||||
}),
|
||||
})
|
||||
})
|
||||
}),
|
||||
})
|
||||
@@ -351,12 +351,10 @@ const make = (dependencies: Dependencies) => {
|
||||
)
|
||||
if (!last) return false
|
||||
const output = Math.min(input.model.route.defaults.limits?.output ?? 0, OUTPUT_TOKEN_MAX)
|
||||
const inputLimit = input.model.route.defaults.limits?.input
|
||||
const limit = inputLimit === undefined ? context - (output || config.buffer) : Math.max(0, inputLimit - config.buffer)
|
||||
const used =
|
||||
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
|
||||
if (used <= 0) return false
|
||||
return used >= limit
|
||||
return used >= context - (output || config.buffer)
|
||||
}
|
||||
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
|
||||
const content = planContent(input.messages, config.tokens)
|
||||
|
||||
@@ -319,10 +319,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
"session.text.ended": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = latestText(draft)
|
||||
if (match) {
|
||||
match.text = event.data.text
|
||||
match.state = castDraft(event.data.state)
|
||||
}
|
||||
if (match) match.text = event.data.text
|
||||
})
|
||||
},
|
||||
"session.tool.input.started": (event) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as SessionRunnerLLM from "./llm"
|
||||
|
||||
import { LLMClient, LLMError, LLMEvent, isContextOverflowFailure, type ProviderErrorEvent, type ToolCall } from "@opencode-ai/ai"
|
||||
import { Cause, Data, Effect, Exit, Fiber, FiberSet, Layer, Option, Pull, Schedule, Stream } from "effect"
|
||||
import { Cause, Data, Effect, Exit, Fiber, FiberSet, Layer, Option, Pull, Schedule, Semaphore, Stream } from "effect"
|
||||
import { Database } from "../../database/database"
|
||||
import { EventV2 } from "../../event"
|
||||
import { PermissionV2 } from "../../permission"
|
||||
@@ -18,7 +18,7 @@ import { SessionSchema } from "../schema"
|
||||
import { SessionStore } from "../store"
|
||||
import { SessionTitle } from "../title"
|
||||
import { Service } from "./index"
|
||||
import { createLLMEventPublisher, type StepRecord } from "./publish-llm-event"
|
||||
import { createLLMEventPublisher } from "./publish-llm-event"
|
||||
import { Snapshot } from "../../snapshot"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { llmClient } from "../../effect/app-node-platform"
|
||||
@@ -70,7 +70,7 @@ const classifyToolExits = (
|
||||
Cause.isFailReason(reason) ? (isDecline(reason.error) ? [] : [Cause.makeFailReason(reason.error)]) : [reason],
|
||||
)
|
||||
return reasons.length > 0 ? [Cause.fromReasons(reasons)] : []
|
||||
}).at(0)
|
||||
})[0]
|
||||
return {
|
||||
interrupted: causes.some(Cause.hasInterrupts),
|
||||
declines,
|
||||
@@ -79,10 +79,6 @@ const classifyToolExits = (
|
||||
}
|
||||
}
|
||||
|
||||
const TOOLS_INTERRUPTED = { type: "aborted", message: "Tool execution interrupted" } as const
|
||||
const STEP_INTERRUPTED = { type: "aborted", message: "Step interrupted" } as const
|
||||
const RESULT_MISSING = { type: "tool.result-missing", message: "Provider did not return a tool result" } as const
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
@@ -230,6 +226,7 @@ const layer = Layer.effect(
|
||||
readonly fiber: Fiber.Fiber<void, SessionModelRequest.ExecuteError>
|
||||
}> = []
|
||||
const interruptTools = Effect.suspend(() => Fiber.interruptAll(toolRuns.map((run) => run.fiber)))
|
||||
let needsContinuation = false
|
||||
const startSnapshot = yield* snapshots.capture()
|
||||
const publisher = createLLMEventPublisher(events, {
|
||||
sessionID: session.id,
|
||||
@@ -241,9 +238,15 @@ const layer = Layer.effect(
|
||||
snapshot: startSnapshot,
|
||||
assistantMessageID,
|
||||
})
|
||||
const stepUsage = (finish: NonNullable<StepRecord["finish"]>) => ({
|
||||
cost: SessionUsage.calculateCost(resolved.cost, finish.tokens),
|
||||
tokens: finish.tokens,
|
||||
const publication = Semaphore.makeUnsafe(1)
|
||||
// Durable publishes are serialized so tool fibers and step settlement never interleave
|
||||
// mid-event.
|
||||
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
|
||||
const publish = (event: LLMEvent) => serialized(publisher.publish(event))
|
||||
|
||||
const stepUsage = (settlement: NonNullable<ReturnType<typeof publisher.stepSettlement>>) => ({
|
||||
cost: SessionUsage.calculateCost(resolved.cost, settlement.tokens),
|
||||
tokens: settlement.tokens,
|
||||
})
|
||||
|
||||
const captureStepEnd = Effect.fnUntraced(function* () {
|
||||
@@ -257,26 +260,20 @@ const layer = Layer.effect(
|
||||
return { snapshot, files }
|
||||
})
|
||||
|
||||
const publishStepEnd = (finish: NonNullable<StepRecord["finish"]>) =>
|
||||
const publishStepEnd = (settlement: NonNullable<ReturnType<typeof publisher.stepSettlement>>) =>
|
||||
Effect.gen(function* () {
|
||||
const end = yield* captureStepEnd()
|
||||
yield* events.publish(SessionEvent.Step.Ended, {
|
||||
sessionID: session.id,
|
||||
assistantMessageID: yield* publisher.startAssistant(),
|
||||
finish: finish.finish,
|
||||
...stepUsage(finish),
|
||||
...end,
|
||||
})
|
||||
yield* serialized(
|
||||
events.publish(SessionEvent.Step.Ended, {
|
||||
sessionID: session.id,
|
||||
assistantMessageID: yield* publisher.startAssistant(),
|
||||
finish: settlement.finish,
|
||||
...stepUsage(settlement),
|
||||
...end,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
// Concurrent writers, no lock: the provider loop and each tool fiber publish
|
||||
// durable events unserialized. This is safe because every publisher method commits
|
||||
// its state marks synchronously before its first await (see publish-llm-event.ts),
|
||||
// every required event order is per-source (each source is one sequential fiber),
|
||||
// and a fiber's events are causally after its own Tool.Called: the fork happens
|
||||
// below that publish. Cross-source order is unconstrained; either interleaving is
|
||||
// a truthful history of concurrent work.
|
||||
//
|
||||
// The stream is defined here but runs inside the settlement mask below: publish each
|
||||
// event durably, fork one fiber per local tool call, and hold back a virgin
|
||||
// context-overflow provider error so settlement may recover it via compaction.
|
||||
@@ -286,13 +283,20 @@ const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
if (overflowFailure || publisher.hasProviderError()) return
|
||||
if (LLMEvent.is.providerError(event)) {
|
||||
if (isContextOverflowFailure(event) && !publisher.record().outputStarted) {
|
||||
if (isContextOverflowFailure(event) && !publisher.hasRetryEvidence()) {
|
||||
overflowFailure = event
|
||||
return
|
||||
}
|
||||
}
|
||||
yield* publisher.publish(event)
|
||||
yield* publish(event)
|
||||
if (LLMEvent.is.toolInputError(event)) {
|
||||
if (!prepared.stepLimitReached) needsContinuation = true
|
||||
return
|
||||
}
|
||||
if (event.type !== "tool-call" || event.providerExecuted) return
|
||||
// Unavailable calls fail individually through the same execution seam;
|
||||
// continuation depends only on remaining Step allowance.
|
||||
if (!prepared.stepLimitReached) needsContinuation = true
|
||||
const assistantMessageID = yield* publisher.assistantMessageID(event.id)
|
||||
toolRuns.push({
|
||||
call: event,
|
||||
@@ -303,23 +307,20 @@ const layer = Layer.effect(
|
||||
agent: agent.id,
|
||||
messageID: assistantMessageID,
|
||||
call: event,
|
||||
// Progress is ephemeral, not durable history: nothing to order.
|
||||
progress: (update) => publisher.progress(event.id, update),
|
||||
progress: (update) => serialized(publisher.progress(event.id, update)),
|
||||
}),
|
||||
).pipe(
|
||||
// The fiber owns its call: it publishes its own completion, masked so a
|
||||
// finished execution always reaches its durable settlement.
|
||||
Effect.flatMap((outcome) => publisher.toolExecution(event.id, event.name, outcome)),
|
||||
Effect.flatMap((execution) => serialized(publisher.toolExecution(event.id, event.name, execution))),
|
||||
),
|
||||
).pipe(Effect.forkScoped),
|
||||
})
|
||||
}),
|
||||
),
|
||||
Effect.ensuring(publisher.flush()),
|
||||
Effect.ensuring(serialized(publisher.flush())),
|
||||
)
|
||||
|
||||
// Settle: only the stream and the fiber joins are interruptible (restore); every
|
||||
// other line is protected so a started call always reaches one durable outcome.
|
||||
// Settle: only the stream itself is interruptible (restore); every line after it is
|
||||
// protected so a started call always reaches one durable outcome.
|
||||
return yield* Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
const stream = yield* restore(providerStream).pipe(Effect.exit)
|
||||
@@ -328,103 +329,107 @@ const layer = Layer.effect(
|
||||
// away non-interrupt failures, so both interrupt checks stay Cause-based.
|
||||
const streamInterrupted = stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)
|
||||
|
||||
// Join every owned tool run first: await all exits, not just the first failure.
|
||||
// Afterwards no fiber is alive, settlement is the only writer, and the record
|
||||
// is final. A failed join means the waiting itself was interrupted, so the runs
|
||||
// we abandoned are interrupted before settlement closes them out.
|
||||
if (streamInterrupted) yield* interruptTools
|
||||
const joined = yield* restore(
|
||||
Effect.forEach(toolRuns, (run) => Fiber.await(run.fiber), { concurrency: "unbounded" }),
|
||||
).pipe(Effect.exit)
|
||||
if (joined._tag === "Failure") yield* interruptTools
|
||||
const tools = classifyToolExits(
|
||||
joined,
|
||||
toolRuns.map((run) => run.call),
|
||||
)
|
||||
|
||||
// A context overflow before any assistant output is recoverable: compact and
|
||||
// restart the step instead of surfacing the provider error.
|
||||
if (
|
||||
recoverOverflow &&
|
||||
!publisher.record().outputStarted &&
|
||||
!publisher.hasRetryEvidence() &&
|
||||
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
|
||||
(yield* restore(compaction.compact(compactionInput))).status === "completed"
|
||||
)
|
||||
return CallOutcome.Restart({ step: currentStep, recoveredOverflow: true })
|
||||
|
||||
// An unrecovered held-back overflow becomes the step's durable provider error.
|
||||
if (overflowFailure) yield* publisher.publish(overflowFailure)
|
||||
// A thrown LLM failure not already recorded as the provider error either
|
||||
// escapes as a scheduled retry or fails the assistant durably.
|
||||
// An unrecovered held-back overflow becomes the step's durable provider error. A
|
||||
// thrown LLM failure records the assistant failure unless a provider error was
|
||||
// already recorded from the stream. Terminal publication waits for owned tools.
|
||||
if (overflowFailure) yield* publish(overflowFailure)
|
||||
const llmFailure = streamFailure instanceof LLMError ? streamFailure : undefined
|
||||
const llmError = llmFailure && !publisher.record().providerFailed ? toSessionError(llmFailure) : undefined
|
||||
if (llmFailure && llmError && SessionRunnerRetry.isRetryable(llmFailure) && !publisher.record().outputStarted) {
|
||||
// RetryScheduled and Step.Failed fold onto an existing assistant message, so
|
||||
// Step.Started must be durable before the failure escapes.
|
||||
yield* publisher.startAssistant()
|
||||
return yield* new SessionRunnerRetry.RetryableFailure({
|
||||
cause: llmFailure,
|
||||
error: llmError,
|
||||
step: currentStep,
|
||||
})
|
||||
if (llmFailure && !publisher.hasProviderError()) {
|
||||
const error = toSessionError(llmFailure)
|
||||
if (SessionRunnerRetry.isRetryable(llmFailure) && !publisher.hasRetryEvidence()) {
|
||||
// RetryScheduled and Step.Failed fold onto an existing assistant message, so
|
||||
// Step.Started must be durable before the failure escapes.
|
||||
yield* serialized(publisher.startAssistant())
|
||||
return yield* new SessionRunnerRetry.RetryableFailure({
|
||||
cause: llmFailure,
|
||||
error,
|
||||
step: currentStep,
|
||||
})
|
||||
}
|
||||
yield* serialized(publisher.failAssistant(error))
|
||||
}
|
||||
if (llmError) yield* publisher.failAssistant(llmError)
|
||||
// Provider error events only arrive from the stream, so the flag is final here.
|
||||
const providerFailed = publisher.hasProviderError()
|
||||
|
||||
// Close every unsettled call with the reason it could not settle truthfully,
|
||||
// and fail the assistant when the step itself cannot complete. A declined call
|
||||
// settles with its own reason before the generic sweeps.
|
||||
// Settle every owned tool run: await all exits, not just the first failure,
|
||||
// before publishing the terminal step event.
|
||||
if (streamInterrupted) yield* interruptTools
|
||||
const settled = yield* restore(
|
||||
Effect.forEach(toolRuns, (run) => Fiber.await(run.fiber), { concurrency: "unbounded" }),
|
||||
).pipe(Effect.exit)
|
||||
if (settled._tag === "Failure") yield* interruptTools
|
||||
const tools = classifyToolExits(
|
||||
settled,
|
||||
toolRuns.map((run) => run.call),
|
||||
)
|
||||
|
||||
// A declined call settles durably with its reason before the generic sweeps.
|
||||
for (const decline of tools.declines)
|
||||
yield* publisher.failTool(decline.call.id, {
|
||||
type: "aborted",
|
||||
message:
|
||||
decline.reason._tag === "QuestionTool.CancelledError"
|
||||
? decline.reason.message
|
||||
: "The user declined this tool call",
|
||||
})
|
||||
yield* serialized(
|
||||
publisher.failTool(decline.call.id, {
|
||||
type: "aborted",
|
||||
message:
|
||||
decline.reason._tag === "QuestionTool.CancelledError"
|
||||
? decline.reason.message
|
||||
: "The user declined this tool call",
|
||||
}),
|
||||
)
|
||||
if (tools.declines.length > 0 || streamInterrupted || tools.interrupted) {
|
||||
yield* publisher.failUnsettledTools(TOOLS_INTERRUPTED)
|
||||
yield* publisher.failAssistant(STEP_INTERRUPTED)
|
||||
yield* serialized(publisher.failUnsettledTools({ type: "aborted", message: "Tool execution interrupted" }))
|
||||
yield* serialized(publisher.failAssistant({ type: "aborted", message: "Step interrupted" }))
|
||||
}
|
||||
if (tools.failure !== undefined) {
|
||||
const error = toSessionError(tools.infraError ?? Cause.squash(tools.failure))
|
||||
yield* publisher.failUnsettledTools(error)
|
||||
if (tools.infraError !== undefined) yield* publisher.failAssistant(error)
|
||||
}
|
||||
// Local calls have joined, so the remaining sweeps only close hosted calls the
|
||||
// provider promised but never resolved.
|
||||
if (publisher.record().providerFailed) yield* publisher.failUnsettledTools(TOOLS_INTERRUPTED)
|
||||
if (llmError) yield* publisher.failUnsettledTools(RESULT_MISSING, "hosted")
|
||||
// A clean stream that still left hosted calls unresolved fails the step itself.
|
||||
if (stream._tag === "Success" && !publisher.record().providerFailed) {
|
||||
const hostedResultMissing = yield* publisher.failUnsettledTools(RESULT_MISSING, "hosted")
|
||||
if (hostedResultMissing && !publisher.record().finish) yield* publisher.failAssistant(RESULT_MISSING)
|
||||
yield* serialized(publisher.failUnsettledTools(error))
|
||||
if (tools.infraError !== undefined) yield* serialized(publisher.failAssistant(error))
|
||||
}
|
||||
|
||||
// One terminal event: Step.Ended on a clean finish, Step.Failed otherwise.
|
||||
const record = publisher.record()
|
||||
if (record.finish && !record.failure) yield* publishStepEnd(record.finish)
|
||||
if (record.failure) {
|
||||
// Fail unresolved calls before the terminal step event. Local calls have joined, so
|
||||
// these sweeps only close calls that could not produce a truthful settlement.
|
||||
if (providerFailed)
|
||||
yield* serialized(publisher.failUnsettledTools({ type: "aborted", message: "Tool execution interrupted" }))
|
||||
const resultMissing = {
|
||||
type: "tool.result-missing",
|
||||
message: "Provider did not return a tool result",
|
||||
} as const
|
||||
if (llmFailure && !providerFailed) yield* serialized(publisher.failUnsettledTools(resultMissing, "hosted"))
|
||||
// A clean stream that still left hosted calls unresolved fails the step itself.
|
||||
if (stream._tag === "Success" && !providerFailed) {
|
||||
const hostedResultMissing = yield* serialized(publisher.failUnsettledTools(resultMissing, "hosted"))
|
||||
if (hostedResultMissing && !publisher.stepSettlement())
|
||||
yield* serialized(publisher.failAssistant(resultMissing))
|
||||
}
|
||||
|
||||
const stepFailure = publisher.stepFailure()
|
||||
const stepSettlement = publisher.stepSettlement()
|
||||
if (stepSettlement && !stepFailure) yield* publishStepEnd(stepSettlement)
|
||||
if (stepFailure) {
|
||||
const end = yield* captureStepEnd()
|
||||
yield* publisher.publishStepFailure({
|
||||
...(record.finish ? stepUsage(record.finish) : {}),
|
||||
...end,
|
||||
})
|
||||
yield* serialized(
|
||||
publisher.publishStepFailure({
|
||||
...(stepSettlement ? stepUsage(stepSettlement) : {}),
|
||||
...end,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
|
||||
if (tools.declines.length > 0) return yield* Effect.interrupt
|
||||
if ((tools.interrupted || tools.infraError !== undefined) && tools.failure)
|
||||
return yield* Effect.failCause(tools.failure)
|
||||
if (tools.interrupted && joined._tag === "Failure") return yield* Effect.failCause(joined.cause)
|
||||
if (record.failure) return yield* new StepFailedError({ error: record.failure })
|
||||
return CallOutcome.Completed({
|
||||
// A local call or malformed tool input requires another model step, unless
|
||||
// this step already exhausted the agent's allowance.
|
||||
needsContinuation:
|
||||
!prepared.stepLimitReached &&
|
||||
record.calls.some((call) => !call.providerExecuted && (call.called || call.settled)),
|
||||
step: currentStep,
|
||||
})
|
||||
if (tools.interrupted && settled._tag === "Failure") return yield* Effect.failCause(settled.cause)
|
||||
if (stepFailure) return yield* new StepFailedError({ error: stepFailure })
|
||||
return CallOutcome.Completed({ needsContinuation, step: currentStep })
|
||||
}),
|
||||
)
|
||||
}, Effect.scoped)
|
||||
|
||||
@@ -23,30 +23,9 @@ type Input = {
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
}
|
||||
|
||||
const asRecord = (value: unknown): Record<string, unknown> =>
|
||||
const record = (value: unknown): Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : { value }
|
||||
|
||||
/** Immutable fold of the durable facts a step's writer has recorded so far. */
|
||||
export interface StepRecord {
|
||||
/** The model produced visible output this attempt, which bars transparent retries and overflow recovery. */
|
||||
readonly outputStarted: boolean
|
||||
readonly providerFailed: boolean
|
||||
/** The step's recorded assistant failure, if any. */
|
||||
readonly failure?: SessionError.Error
|
||||
/** Present once the provider finished the step normally. */
|
||||
readonly finish?: {
|
||||
readonly finish: Extract<LLMEvent, { type: "step-finish" }>["reason"]["normalized"]
|
||||
readonly tokens: ReturnType<typeof SessionUsage.tokens>
|
||||
}
|
||||
readonly calls: ReadonlyArray<{
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly called: boolean
|
||||
readonly settled: boolean
|
||||
readonly providerExecuted: boolean
|
||||
}>
|
||||
}
|
||||
|
||||
/** Derives canonical model content from a provider-hosted tool result. */
|
||||
const hostedContent = (result: ToolResultValue): Tool.NonEmptyContent => {
|
||||
if (result.type === "content") {
|
||||
@@ -56,17 +35,7 @@ const hostedContent = (result: ToolResultValue): Tool.NonEmptyContent => {
|
||||
return [{ type: "text", text: Tool.stringify(result.value) }]
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist one step without executing tools or starting a continuation step.
|
||||
*
|
||||
* Concurrency invariant: the provider loop and each owned tool fiber call these methods
|
||||
* concurrently without a lock. Two rules keep that safe, and every method must preserve
|
||||
* them. (1) Commit state marks synchronously before the first await: never a yield
|
||||
* between a check (`tool.settled`, `stepStarted`, ...) and its mark, so check-and-mark
|
||||
* stays atomic under cooperative scheduling. (2) Never require a cross-source event
|
||||
* order: each publishing fiber is sequential, so per-source order holds by construction,
|
||||
* and consumers fold by callID/ordinal rather than global position.
|
||||
*/
|
||||
/** Persist one step without executing tools or starting a continuation step. */
|
||||
export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish">, input: Input) => {
|
||||
const tools = new Map<
|
||||
string,
|
||||
@@ -85,9 +54,14 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
let stepStarted = false
|
||||
let stepFailed = false
|
||||
let providerFailed = false
|
||||
let outputStarted = false
|
||||
let retryEvidence = false
|
||||
let stepFailure: SessionError.Error | undefined
|
||||
let stepSettlement: StepRecord["finish"]
|
||||
let stepSettlement:
|
||||
| {
|
||||
readonly finish: Extract<LLMEvent, { type: "step-finish" }>["reason"]["normalized"]
|
||||
readonly tokens: ReturnType<typeof SessionUsage.tokens>
|
||||
}
|
||||
| undefined
|
||||
|
||||
const startAssistant = Effect.fnUntraced(function* () {
|
||||
if (stepStarted) return assistantMessageID
|
||||
@@ -149,14 +123,13 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
|
||||
const text = fragments(
|
||||
"text",
|
||||
(_textID, value, ordinal, state) =>
|
||||
(_textID, value, ordinal) =>
|
||||
Effect.gen(function* () {
|
||||
yield* events.publish(SessionEvent.Text.Ended, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: yield* currentAssistantMessageID(),
|
||||
ordinal,
|
||||
text: value,
|
||||
state,
|
||||
})
|
||||
}),
|
||||
true,
|
||||
@@ -326,8 +299,8 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
yield* startAssistant()
|
||||
return
|
||||
case "text-start":
|
||||
outputStarted = true
|
||||
const startedTextOrdinal = yield* text.start(event.id, providerState(event.providerMetadata))
|
||||
retryEvidence = true
|
||||
const startedTextOrdinal = yield* text.start(event.id)
|
||||
yield* events.publish(SessionEvent.Text.Started, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: yield* startAssistant(),
|
||||
@@ -335,7 +308,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
})
|
||||
return
|
||||
case "text-delta":
|
||||
const deltaTextOrdinal = yield* text.append(event.id, event.text, providerState(event.providerMetadata))
|
||||
const deltaTextOrdinal = yield* text.append(event.id, event.text)
|
||||
yield* events.publish(SessionEvent.Text.Delta, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: yield* currentAssistantMessageID(),
|
||||
@@ -344,10 +317,10 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
})
|
||||
return
|
||||
case "text-end":
|
||||
yield* text.end(event.id, providerState(event.providerMetadata))
|
||||
yield* text.end(event.id)
|
||||
return
|
||||
case "reasoning-start":
|
||||
outputStarted = true
|
||||
retryEvidence = true
|
||||
const startedReasoningOrdinal = yield* reasoning.start(event.id, providerState(event.providerMetadata))
|
||||
yield* events.publish(SessionEvent.Reasoning.Started, {
|
||||
sessionID: input.sessionID,
|
||||
@@ -373,7 +346,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
yield* reasoning.end(event.id, providerState(event.providerMetadata))
|
||||
return
|
||||
case "tool-input-start":
|
||||
outputStarted = true
|
||||
retryEvidence = true
|
||||
yield* startToolInput(event)
|
||||
return
|
||||
case "tool-input-delta": {
|
||||
@@ -395,11 +368,11 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
yield* endToolInput(event)
|
||||
return
|
||||
case "tool-input-error":
|
||||
outputStarted = true
|
||||
retryEvidence = true
|
||||
yield* failMalformedToolInput(event)
|
||||
return
|
||||
case "tool-call": {
|
||||
outputStarted = true
|
||||
retryEvidence = true
|
||||
if (!tools.has(event.id)) yield* startToolInput(event)
|
||||
const tool = tools.get(event.id)!
|
||||
if (toolInput.has(event.id)) yield* endToolInput(event)
|
||||
@@ -412,7 +385,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
callID: event.id,
|
||||
input: asRecord(event.input),
|
||||
input: record(event.input),
|
||||
executed: tool.providerExecuted,
|
||||
state: providerState(event.providerMetadata),
|
||||
})
|
||||
@@ -562,20 +535,9 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
publishStepFailure,
|
||||
failUnsettledTools,
|
||||
hasProviderError: () => providerFailed,
|
||||
/** Immutable snapshot of everything recorded for this step so far. */
|
||||
record: (): StepRecord => ({
|
||||
outputStarted,
|
||||
providerFailed,
|
||||
failure: stepFailure,
|
||||
finish: stepSettlement,
|
||||
calls: Array.from(tools, ([id, tool]) => ({
|
||||
id,
|
||||
name: tool.name,
|
||||
called: tool.called,
|
||||
settled: tool.settled,
|
||||
providerExecuted: tool.providerExecuted,
|
||||
})),
|
||||
}),
|
||||
hasRetryEvidence: () => retryEvidence,
|
||||
stepFailure: () => stepFailure,
|
||||
stepSettlement: () => stepSettlement,
|
||||
startAssistant,
|
||||
assistantMessageID: assistantMessageIDForTool,
|
||||
}
|
||||
|
||||
@@ -113,14 +113,7 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref, provid
|
||||
const sameModel = sameProvider && String(message.model.id) === String(model.id)
|
||||
const reuseProviderMetadata = sameModel && message.error === undefined
|
||||
const content = message.content.flatMap((item): ContentPart[] => {
|
||||
if (item.type === "text")
|
||||
return [
|
||||
{
|
||||
type: "text",
|
||||
text: item.text,
|
||||
providerMetadata: sameProvider ? providerMetadata(providerMetadataKey, item.state) : undefined,
|
||||
},
|
||||
]
|
||||
if (item.type === "text") return [{ type: "text", text: item.text }]
|
||||
if (item.type === "reasoning")
|
||||
return reuseProviderMetadata
|
||||
? [
|
||||
|
||||
@@ -36,8 +36,7 @@ type CollectedFiles = {
|
||||
const description = [
|
||||
"Run JavaScript to orchestrate tool calls and compose their results through `{ code }` in a confined Code Mode runtime.",
|
||||
"Imports, direct filesystem access, and timers are unavailable. Do not use `fetch`; all external access goes through `tools`.",
|
||||
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by `search`. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
|
||||
'Call tools through `tools` using only exact paths and signatures from the catalog. Do not infer or normalize tool names; preserve bracket notation such as `tools.<namespace>["tool-name"](input)`.',
|
||||
'Call Code Mode tools through `tools` using only exact paths and signatures from the current catalog or `search`. Do not infer or normalize tool names; preserve bracket notation such as `tools.<namespace>["tool-name"](input)`.',
|
||||
"Prefer an explicit `return`; if omitted, the final top-level expression becomes the result.",
|
||||
"Await every call whose completion matters; pending calls are interrupted when execution ends. Run independent calls concurrently with `Promise.all`.",
|
||||
].join("\n")
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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]
|
||||
}),
|
||||
)
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
@@ -67,7 +67,9 @@ describe("CodeModeInstructions.render", () => {
|
||||
expect(instructions).toContain(` - ${lookup.signature} // Look up an order by ID`)
|
||||
expect(instructions).not.toContain("## Search")
|
||||
expect(instructions).toContain("The Code Mode tool catalog below is complete.")
|
||||
expect(instructions).not.toContain("surrounding top-level agent tools")
|
||||
expect(instructions).toContain(
|
||||
"`tools` contains only the tools shown below; surrounding top-level agent tools are not available and must not be called from the code.",
|
||||
)
|
||||
})
|
||||
|
||||
test("adds search guidance when the catalog exceeds the budget", () => {
|
||||
@@ -76,7 +78,9 @@ describe("CodeModeInstructions.render", () => {
|
||||
expect(partial).toContain("- orders (1 tool, none shown)")
|
||||
expect(partial).toContain("## Search")
|
||||
expect(partial).toContain("The Code Mode tool catalog below is partial.")
|
||||
expect(partial).not.toContain("surrounding top-level agent tools")
|
||||
expect(partial).toContain(
|
||||
"`tools` contains only the tools shown below or returned by `search`; surrounding top-level agent tools are not available and must not be called from the code.",
|
||||
)
|
||||
expect(partial).toContain("- search(input: {")
|
||||
expect(partial).toContain(" limit?: number,\n offset?: number,")
|
||||
expect(partial).not.toContain("tools.orders.lookup(input:")
|
||||
@@ -113,9 +117,7 @@ describe("CodeModeInstructions.render", () => {
|
||||
})
|
||||
|
||||
test("renders only the no-tools notice for an empty catalog", () => {
|
||||
expect(render([])).toBe(
|
||||
"No Code Mode tools are currently available. Later Code Mode catalog updates may add or remove tools. Do not call `execute` unless there is at least one available Code Mode tool.",
|
||||
)
|
||||
expect(render([])).toBe("No tools are currently available.")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -125,8 +127,7 @@ describe("CodeModeInstructions.update", () => {
|
||||
test("renders additions, changes, and removals as a compact semantic delta", () => {
|
||||
const changed = { ...echo, signature: "tools.notes.echo(input: {\n text: string,\n}): Promise<string>" }
|
||||
const added = entry("notes.list", "List notes")
|
||||
const unchanged = Array.from({ length: 5 }, (_, index) => entry(`stable.tool${index}`, `Stable ${index}`))
|
||||
const text = update([echo, lookup, ...unchanged], [changed, added, ...unchanged])
|
||||
const text = update([echo, lookup], [changed, added])
|
||||
expect(text).toContain("The Code Mode tool catalog has changed.")
|
||||
expect(text).toContain(`New tools are available in addition to those previously listed:\n - ${added.signature}`)
|
||||
expect(text).toContain(
|
||||
|
||||
@@ -21,25 +21,6 @@ const lookup: CodeModeCatalog.Entry = {
|
||||
}
|
||||
|
||||
describe("CodeModeInstructions", () => {
|
||||
it.effect("instructs the model not to call execute while the catalog is empty", () =>
|
||||
Effect.gen(function* () {
|
||||
const initialized = yield* readInitial(CodeModeInstructions.make([]))
|
||||
expect(initialized.text).toBe(
|
||||
"No Code Mode tools are currently available. Later Code Mode catalog updates may add or remove tools. Do not call `execute` unless there is at least one available Code Mode tool.",
|
||||
)
|
||||
|
||||
const added = yield* readUpdate(CodeModeInstructions.make([echo]), initialized)
|
||||
expect(added.text).toContain("New tools are available in addition to those previously listed:")
|
||||
expect(added.text).toContain(echo.signature)
|
||||
|
||||
expect(yield* readUpdate(CodeModeInstructions.make([]), { values: added.values })).toMatchObject({
|
||||
text:
|
||||
"The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.\n\n" +
|
||||
"No Code Mode tools are currently available. Later Code Mode catalog updates may add or remove tools. Do not call `execute` unless there is at least one available Code Mode tool.",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("renders the initial catalog, semantic deltas, and removal", () =>
|
||||
Effect.gen(function* () {
|
||||
const initialized = yield* readInitial(CodeModeInstructions.make([echo]))
|
||||
|
||||
@@ -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 },
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -32,38 +32,18 @@ export function waitForTool(
|
||||
})
|
||||
}
|
||||
|
||||
export function waitForCodeModeTool(
|
||||
registry: ToolRegistry.Interface,
|
||||
path: string,
|
||||
remaining = 1000,
|
||||
): Effect.Effect<ToolRegistry.ToolSet, Error> {
|
||||
return Effect.gen(function* () {
|
||||
const toolSet = yield* registry.snapshot()
|
||||
if (toolSet.codeModeCatalog?.some((tool) => tool.path === path)) return toolSet
|
||||
if (remaining === 0) {
|
||||
return yield* Effect.fail(new Error(`Timed out waiting for Code Mode tool: ${path}`))
|
||||
}
|
||||
yield* Effect.promise(() => Bun.sleep(1))
|
||||
return yield* waitForCodeModeTool(registry, path, remaining - 1)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a core tool plugin's tools against the real registry without booting the
|
||||
* 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 }),
|
||||
},
|
||||
|
||||
@@ -33,7 +33,7 @@ import { Image } from "@opencode-ai/core/image"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { imagePassthrough } from "./lib/image"
|
||||
import { location } from "./fixture/location"
|
||||
import { executeTool, toolDefinitions, toolIdentity, waitForCodeModeTool, waitForTool } from "./lib/tool"
|
||||
import { executeTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
|
||||
|
||||
let assertion: Deferred.Deferred<PermissionV2.AssertInput> | undefined
|
||||
let decision: Effect.Effect<void, PermissionV2.Error> = Effect.void
|
||||
@@ -802,16 +802,10 @@ test("serializes concurrent MCP lifecycle operations", async () => {
|
||||
it.effect("advertises MCP output schemas to Code Mode", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const toolSet = yield* waitForCodeModeTool(registry, "demo.search")
|
||||
const execute = toolSet.definitions.find((tool) => tool.name === "execute")
|
||||
yield* waitForTool(registry, "execute")
|
||||
const definitions = yield* toolDefinitions(registry)
|
||||
const execute = definitions.find((tool) => tool.name === "execute")
|
||||
|
||||
expect(toolSet.definitions.map((tool) => tool.name)).toEqual([
|
||||
"direct_fail",
|
||||
"direct_lookup",
|
||||
"direct_media",
|
||||
"execute",
|
||||
])
|
||||
expect(toolSet.codeModeCatalog?.find((tool) => tool.path === "demo.search")?.signature).toContain("ok: boolean")
|
||||
expect(execute?.description).not.toContain("tools.demo.search")
|
||||
}),
|
||||
)
|
||||
@@ -879,9 +873,9 @@ it.effect("waits for permission before calling an MCP tool", () =>
|
||||
const permission = yield* Deferred.make<void>()
|
||||
decision = Deferred.await(permission)
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const toolSet = yield* waitForCodeModeTool(registry, "demo.search")
|
||||
yield* waitForTool(registry, "execute")
|
||||
|
||||
const fiber = yield* toolSet.execute({
|
||||
const fiber = yield* executeTool(registry, {
|
||||
sessionID: SessionV2.ID.make("ses_mcp_permission"),
|
||||
...toolIdentity,
|
||||
call: {
|
||||
@@ -918,9 +912,9 @@ it.effect("does not call MCP when permission is blocked", () =>
|
||||
assertion = yield* Deferred.make<PermissionV2.AssertInput>()
|
||||
decision = Effect.fail(new PermissionV2.BlockedError({ rules: [], permission: "demo_search", resources: ["*"] }))
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const toolSet = yield* waitForCodeModeTool(registry, "demo.search")
|
||||
yield* waitForTool(registry, "execute")
|
||||
|
||||
const execution = yield* toolSet.execute({
|
||||
const execution = yield* executeTool(registry, {
|
||||
sessionID: SessionV2.ID.make("ses_mcp_blocked"),
|
||||
...toolIdentity,
|
||||
call: {
|
||||
|
||||
@@ -17,7 +17,6 @@ interface ModelOptions {
|
||||
readonly headers?: ModelV2.Info["headers"]
|
||||
readonly body?: ModelV2.Info["body"]
|
||||
readonly variants?: ModelV2.Info["variants"]
|
||||
readonly limit?: ModelV2.Info["limit"]
|
||||
}
|
||||
|
||||
const model = (packageName: string | undefined, options: ModelOptions = {}) =>
|
||||
@@ -37,7 +36,7 @@ const model = (packageName: string | undefined, options: ModelOptions = {}) =>
|
||||
cost: [],
|
||||
status: "active",
|
||||
enabled: true,
|
||||
limit: options.limit ?? { context: 100, output: 20 },
|
||||
limit: { context: 100, output: 20 },
|
||||
})
|
||||
|
||||
describe("ModelResolver", () => {
|
||||
@@ -45,7 +44,6 @@ describe("ModelResolver", () => {
|
||||
Effect.gen(function* () {
|
||||
const catalog = model(ProviderV2.aisdk("@ai-sdk/openai"), {
|
||||
settings: { baseURL: "https://openai.example/v1" },
|
||||
limit: { context: 100, input: 70, output: 20 },
|
||||
})
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(catalog)
|
||||
|
||||
@@ -57,7 +55,7 @@ describe("ModelResolver", () => {
|
||||
endpoint: { baseURL: "https://openai.example/v1" },
|
||||
defaults: {
|
||||
headers: { "x-test": "header" },
|
||||
limits: { context: 100, input: 70, output: 20 },
|
||||
limits: { context: 100, output: 20 },
|
||||
http: { body: { custom_extension: { enabled: true } } },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -164,16 +164,6 @@ describe("OpenAIPlugin", () => {
|
||||
const catalog = yield* Catalog.Service
|
||||
const credentials = yield* Credential.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
const codex = ProviderV2.Info.make({
|
||||
...ProviderV2.Info.empty(ProviderV2.ID.make("openai-codex")),
|
||||
package: ProviderV2.aisdk("@ai-sdk/openai"),
|
||||
})
|
||||
catalog.provider.update(codex.id, (draft) => {
|
||||
draft.package = codex.package
|
||||
})
|
||||
catalog.model.update(codex.id, ModelV2.ID.make("gpt-5.6-sol"), (model) => {
|
||||
model.limit = { context: 400_000, input: 272_000, output: 128_000 }
|
||||
})
|
||||
const item = ProviderV2.Info.make({
|
||||
...ProviderV2.Info.empty(ProviderV2.ID.openai),
|
||||
package: ProviderV2.aisdk("@ai-sdk/openai"),
|
||||
@@ -199,9 +189,7 @@ describe("OpenAIPlugin", () => {
|
||||
model.body = { reasoning: { mode: "pro" } }
|
||||
})
|
||||
catalog.model.update(item.id, ModelV2.ID.make("gpt-5.6"), () => {})
|
||||
catalog.model.update(item.id, ModelV2.ID.make("gpt-5.6-sol"), (model) => {
|
||||
model.limit = { context: 1_050_000, input: 922_000, output: 128_000 }
|
||||
})
|
||||
catalog.model.update(item.id, ModelV2.ID.make("gpt-5.6-sol"), () => {})
|
||||
catalog.model.update(item.id, ModelV2.ID.make("gpt-4.1"), () => {})
|
||||
})
|
||||
yield* credentials.create({
|
||||
@@ -227,13 +215,9 @@ describe("OpenAIPlugin", () => {
|
||||
false,
|
||||
)
|
||||
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.6"))).enabled).toBe(false)
|
||||
const sol = required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.6-sol")))
|
||||
expect(sol.enabled).toBe(true)
|
||||
expect(sol.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
|
||||
expect(
|
||||
required(yield* catalog.model.get(ProviderV2.ID.make("openai-codex"), ModelV2.ID.make("gpt-5.6-sol")))
|
||||
.enabled,
|
||||
).toBe(false)
|
||||
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.6-sol"))).enabled).toBe(
|
||||
true,
|
||||
)
|
||||
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-4.1"))).enabled).toBe(false)
|
||||
}),
|
||||
)
|
||||
@@ -243,16 +227,6 @@ describe("OpenAIPlugin", () => {
|
||||
const catalog = yield* Catalog.Service
|
||||
const credentials = yield* Credential.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
const codex = ProviderV2.Info.make({
|
||||
...ProviderV2.Info.empty(ProviderV2.ID.make("openai-codex")),
|
||||
package: ProviderV2.aisdk("@ai-sdk/openai"),
|
||||
})
|
||||
catalog.provider.update(codex.id, (draft) => {
|
||||
draft.package = codex.package
|
||||
})
|
||||
catalog.model.update(codex.id, ModelV2.ID.make("gpt-5.5"), (model) => {
|
||||
model.limit = { context: 400_000, input: 272_000, output: 128_000 }
|
||||
})
|
||||
const item = ProviderV2.Info.make({
|
||||
...ProviderV2.Info.empty(ProviderV2.ID.openai),
|
||||
package: ProviderV2.aisdk("@ai-sdk/openai"),
|
||||
@@ -260,9 +234,7 @@ describe("OpenAIPlugin", () => {
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.package = item.package
|
||||
})
|
||||
catalog.model.update(item.id, ModelV2.ID.make("gpt-5.5"), (model) => {
|
||||
model.limit = { context: 1_050_000, input: 922_000, output: 128_000 }
|
||||
})
|
||||
catalog.model.update(item.id, ModelV2.ID.make("gpt-5.5"), () => {})
|
||||
catalog.model.update(item.id, ModelV2.ID.make("gpt-4.1"), () => {})
|
||||
})
|
||||
yield* credentials.create({
|
||||
@@ -271,9 +243,7 @@ describe("OpenAIPlugin", () => {
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
||||
const model = required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.5")))
|
||||
expect(model.enabled).toBe(true)
|
||||
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
|
||||
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.5"))).enabled).toBe(true)
|
||||
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-4.1"))).enabled).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
@@ -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")
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -16,15 +16,11 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { App } from "@opencode-ai/core/app"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { DateTime, Effect, Fiber, Layer, Stream } from "effect"
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
import { testEffect } from "./lib/effect"
|
||||
@@ -135,40 +131,6 @@ test("compaction prompt requires the checkpoint headings in order", () => {
|
||||
expect(prompt).toContain("Keep every section, even when empty.")
|
||||
})
|
||||
|
||||
it.effect("auto compaction uses the model input limit", () =>
|
||||
Effect.gen(function* () {
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
const session = SessionV2.Info.make({
|
||||
id: SessionV2.ID.make("ses_input_limit"),
|
||||
projectID: Project.ID.global,
|
||||
title: "Input limit",
|
||||
cost: Money.USD.zero,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/project") }),
|
||||
})
|
||||
const route = Model.make({
|
||||
id: "codex-model",
|
||||
provider: "openai",
|
||||
route: OpenAIChat.route.with({ limits: { context: 400_000, input: 272_000, output: 128_000 } }),
|
||||
})
|
||||
const message = (input: number) =>
|
||||
SessionMessage.Assistant.make({
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "assistant",
|
||||
agent: AgentV2.defaultID,
|
||||
model: { id: ModelV2.ID.make("codex-model"), providerID: ProviderV2.ID.openai },
|
||||
content: [],
|
||||
cost: Money.USD.zero,
|
||||
tokens: { input, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
})
|
||||
|
||||
expect(compaction.required({ session, model: route, cost: [], messages: [message(251_999)] })).toBe(false)
|
||||
expect(compaction.required({ session, model: route, cost: [], messages: [message(252_000)] })).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
|
||||
@@ -767,35 +767,4 @@ Recent work
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("preserves assistant text provider state across same-provider model changes and failures", () => {
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
SessionMessage.Assistant.make({
|
||||
id: id("assistant-phase"),
|
||||
type: "assistant",
|
||||
agent: build,
|
||||
model: { id: ModelV2.ID.make("old"), providerID: ProviderV2.ID.make("provider") },
|
||||
content: [
|
||||
SessionMessage.AssistantText.make({
|
||||
type: "text",
|
||||
text: "Checking.",
|
||||
state: { phase: "commentary" },
|
||||
}),
|
||||
],
|
||||
error: { type: "provider.unknown", message: "Interrupted after commentary" },
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
],
|
||||
ModelV2.Ref.make({ id: ModelV2.ID.make("new"), providerID: ProviderV2.ID.make("provider") }),
|
||||
)
|
||||
|
||||
expect(messages[0]?.content).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: "Checking.",
|
||||
providerMetadata: { provider: { phase: "commentary" } },
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -186,7 +186,6 @@ describe("SessionRunnerLLM recorded", () => {
|
||||
yield* agents.transform((draft) =>
|
||||
draft.update(AgentV2.ID.make("build"), (agent) => {
|
||||
agent.mode = "primary"
|
||||
agent.permissions.push({ action: "execute", resource: "*", effect: "deny" })
|
||||
}),
|
||||
)
|
||||
const pluginHost = host({
|
||||
|
||||
@@ -259,7 +259,7 @@ test("step finish records settlement without publishing step ended", async () =>
|
||||
await Effect.runPromise(publisher.publish(LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } })))
|
||||
|
||||
expect(published.some((event) => event.type === "step.ended.2")).toBe(false)
|
||||
expect(publisher.record().finish).toMatchObject({ finish: "stop" })
|
||||
expect(publisher.stepSettlement()).toMatchObject({ finish: "stop" })
|
||||
})
|
||||
|
||||
test("content-filter finish retains failure evidence until step closeout", async () => {
|
||||
@@ -280,7 +280,7 @@ test("content-filter finish retains failure evidence until step closeout", async
|
||||
)
|
||||
|
||||
expect(published.map((event) => event.type)).toEqual(["session.step.started.1"])
|
||||
const settlement = publisher.record().finish
|
||||
const settlement = publisher.stepSettlement()
|
||||
expect(settlement).toMatchObject({
|
||||
finish: "content-filter",
|
||||
tokens: { input: 8, output: 2, reasoning: 1 },
|
||||
|
||||
@@ -88,7 +88,7 @@ describe("ToolRegistry", () => {
|
||||
|
||||
expect(error).toBeInstanceOf(Tool.RegistrationError)
|
||||
expect(error.message).toBe('Invalid tool namespace: "slack..admin"')
|
||||
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
|
||||
expect((yield* service.snapshot()).definitions).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -102,7 +102,7 @@ describe("ToolRegistry", () => {
|
||||
.register({ "echo.tool": make(), echo_tool: make() }, { codemode: false })
|
||||
.pipe(Effect.flip)
|
||||
expect(collision.message).toBe("Duplicate normalized tool name: echo_tool")
|
||||
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
|
||||
expect((yield* service.snapshot()).definitions).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -117,7 +117,7 @@ describe("ToolRegistry", () => {
|
||||
.pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(Tool.RegistrationError)
|
||||
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
|
||||
expect((yield* service.snapshot()).definitions).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -148,20 +148,6 @@ describe("ToolRegistry", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps execute available without Code Mode tools unless explicitly denied", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
|
||||
const available = yield* service.snapshot()
|
||||
expect(available.definitions.map((tool) => tool.name)).toEqual(["execute"])
|
||||
expect(available.codeModeCatalog).toEqual([])
|
||||
|
||||
const denied = yield* service.snapshot([{ action: "execute", resource: "*", effect: "deny" }])
|
||||
expect(denied.definitions).toEqual([])
|
||||
expect(denied.codeModeCatalog).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
@@ -170,12 +156,7 @@ describe("ToolRegistry", () => {
|
||||
const names = (permissions: PermissionV2.Ruleset) =>
|
||||
toolDefinitions(service, permissions).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name)))
|
||||
|
||||
expect(yield* names([{ action: "question", resource: "*", effect: "deny" }])).toEqual([
|
||||
"bash",
|
||||
"edit",
|
||||
"write",
|
||||
"execute",
|
||||
])
|
||||
expect(yield* names([{ action: "question", resource: "*", effect: "deny" }])).toEqual(["bash", "edit", "write"])
|
||||
expect(
|
||||
yield* names([
|
||||
{ action: "*", resource: "*", effect: "deny" },
|
||||
@@ -188,11 +169,7 @@ describe("ToolRegistry", () => {
|
||||
{ action: "*", resource: "*", effect: "deny" },
|
||||
]),
|
||||
).toEqual([])
|
||||
expect(yield* names([{ action: "edit", resource: "*", effect: "deny" }])).toEqual([
|
||||
"bash",
|
||||
"question",
|
||||
"execute",
|
||||
])
|
||||
expect(yield* names([{ action: "edit", resource: "*", effect: "deny" }])).toEqual(["bash", "question"])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -205,7 +182,7 @@ describe("ToolRegistry", () => {
|
||||
|
||||
expect(
|
||||
(yield* toolDefinitions(service, [{ action: "edit", resource: "*", effect: "deny" }])).map((tool) => tool.name),
|
||||
).toEqual(["first", "execute"])
|
||||
).toEqual(["first"])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -214,9 +191,9 @@ describe("ToolRegistry", () => {
|
||||
const service = yield* ToolRegistry.Service
|
||||
const scope = yield* Scope.make()
|
||||
yield* service.register({ echo: make() }, { codemode: false }).pipe(Scope.provide(scope))
|
||||
expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["echo", "execute"])
|
||||
expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["echo"])
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["execute"])
|
||||
expect(yield* toolDefinitions(service)).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -236,9 +213,9 @@ describe("ToolRegistry", () => {
|
||||
yield* Deferred.await(registered)
|
||||
yield* Fiber.interrupt(fiber)
|
||||
|
||||
expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["echo", "execute"])
|
||||
expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["echo"])
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["execute"])
|
||||
expect(yield* toolDefinitions(service)).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -880,46 +880,6 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("advertises execute and durable guidance for an empty Code Mode catalog", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const empty = {
|
||||
catalog: [],
|
||||
tool: Tool.make({
|
||||
description: "Execute Code Mode",
|
||||
input: Schema.Struct({ code: Schema.String }),
|
||||
output: Schema.String,
|
||||
execute: () => Effect.succeed({ output: "unused" }),
|
||||
}),
|
||||
}
|
||||
codeModeMaterializations = [empty, empty, {}]
|
||||
yield* admit(session, "Continue without Code Mode tools")
|
||||
response = reply.stop()
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
yield* admit(session, "Still no Code Mode tools")
|
||||
yield* session.resume(sessionID)
|
||||
yield* admit(session, "Code Mode denied")
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(3)
|
||||
expect(requests[0]?.tools.map((tool) => tool.name)).toEqual(["defect", "echo", "storefail", "execute"])
|
||||
expect(requests[0]?.system.some((part) => part.text.includes("Do not call `execute`"))).toBe(true)
|
||||
expect(requests[1]?.tools.map((tool) => tool.name)).toEqual(["defect", "echo", "storefail", "execute"])
|
||||
expect(requests[1]?.messages.filter((message) => message.role === "system")).toEqual([])
|
||||
expect(requests[2]?.tools.map((tool) => tool.name)).toEqual(["defect", "echo", "storefail"])
|
||||
expect(
|
||||
requests[2]?.messages.some(
|
||||
(message) =>
|
||||
message.role === "system" &&
|
||||
message.content.some(
|
||||
(part) => part.type === "text" && part.text.includes("Code Mode tools are no longer available"),
|
||||
),
|
||||
),
|
||||
).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("applies session context hooks without exposing unavailable tools", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
@@ -2672,47 +2632,6 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("restores durable text provider metadata in the next request", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Check first")
|
||||
|
||||
response = [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.textStart({ id: "commentary", providerMetadata: { openai: { phase: "commentary" } } }),
|
||||
LLMEvent.textDelta({ id: "commentary", text: "Checking." }),
|
||||
LLMEvent.textEnd({
|
||||
id: "commentary",
|
||||
providerMetadata: { openai: { phase: "commentary" }, anthropic: { ignored: true } },
|
||||
}),
|
||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
||||
]
|
||||
yield* session.resume(sessionID)
|
||||
yield* replaySessionProjection(sessionID)
|
||||
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user", text: "Check first" },
|
||||
{
|
||||
type: "assistant",
|
||||
content: [{ type: "text", text: "Checking.", state: { phase: "commentary" } }],
|
||||
},
|
||||
])
|
||||
|
||||
yield* admit(session, "Continue")
|
||||
response = []
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests[1]?.messages[1]?.content).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: "Checking.",
|
||||
providerMetadata: { openai: { phase: "commentary" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replays durable provider-executed tool results inline in the next request", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
|
||||
@@ -137,12 +137,10 @@ describe("EditTool", () => {
|
||||
Effect.andThen(
|
||||
withTool(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["edit", "execute"])
|
||||
expect(
|
||||
(yield* toolDefinitions(registry, [{ action: "edit", resource: "*", effect: "deny" }])).map(
|
||||
(tool) => tool.name,
|
||||
),
|
||||
).toEqual(["execute"])
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["edit"])
|
||||
expect(yield* toolDefinitions(registry, [{ action: "edit", resource: "*", effect: "deny" }])).toEqual(
|
||||
[],
|
||||
)
|
||||
const settled = yield* executeTool(
|
||||
registry,
|
||||
call({ path: "hello.txt", oldString: "before", newString: "after" }),
|
||||
|
||||
@@ -19,8 +19,7 @@ test("execute describes invariant Code Mode behavior", () => {
|
||||
[
|
||||
"Run JavaScript to orchestrate tool calls and compose their results through `{ code }` in a confined Code Mode runtime.",
|
||||
"Imports, direct filesystem access, and timers are unavailable. Do not use `fetch`; all external access goes through `tools`.",
|
||||
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by `search`. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
|
||||
'Call tools through `tools` using only exact paths and signatures from the catalog. Do not infer or normalize tool names; preserve bracket notation such as `tools.<namespace>["tool-name"](input)`.',
|
||||
'Call Code Mode tools through `tools` using only exact paths and signatures from the current catalog or `search`. Do not infer or normalize tool names; preserve bracket notation such as `tools.<namespace>["tool-name"](input)`.',
|
||||
"Prefer an explicit `return`; if omitted, the final top-level expression becomes the result.",
|
||||
"Await every call whose completion matters; pending calls are interrupted when execution ends. Run independent calls concurrently with `Promise.all`.",
|
||||
].join("\n"),
|
||||
|
||||
@@ -181,7 +181,7 @@ describe("PatchTool", () => {
|
||||
Effect.andThen(
|
||||
withTool(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["patch", "execute"])
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["patch"])
|
||||
const settled = yield* executeTool(
|
||||
registry,
|
||||
call(
|
||||
|
||||
@@ -97,11 +97,7 @@ describe("QuestionTool", () => {
|
||||
deny = true
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect(
|
||||
(yield* toolDefinitions(registry, [{ action: "question", resource: "*", effect: "deny" }])).map(
|
||||
(tool) => tool.name,
|
||||
),
|
||||
).toEqual(["execute"])
|
||||
expect(yield* toolDefinitions(registry, [{ action: "question", resource: "*", effect: "deny" }])).toEqual([])
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
@@ -146,7 +142,7 @@ describe("QuestionTool", () => {
|
||||
},
|
||||
]
|
||||
|
||||
expect((yield* toolDefinitions(registry)).map((definition) => definition.name)).toEqual(["question", "execute"])
|
||||
expect((yield* toolDefinitions(registry)).map((definition) => definition.name)).toEqual(["question"])
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
|
||||
@@ -197,12 +197,8 @@ describe("ReadTool", () => {
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["read", "execute"])
|
||||
expect(
|
||||
(yield* toolDefinitions(registry, [{ action: "read", resource: "*", effect: "deny" }])).map(
|
||||
(tool) => tool.name,
|
||||
),
|
||||
).toEqual(["execute"])
|
||||
expect(yield* toolDefinitions(registry)).toMatchObject([{ name: "read" }])
|
||||
expect(yield* toolDefinitions(registry, [{ action: "read", resource: "*", effect: "deny" }])).toEqual([])
|
||||
const execution = yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
|
||||
@@ -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()])),
|
||||
|
||||
@@ -92,7 +92,7 @@ describe("WebFetchTool registration", () => {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const url = "http://example.com/public"
|
||||
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["webfetch", "execute"])
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["webfetch"])
|
||||
expect(yield* executeTool(registry, call({ url, format: "text", timeout: 4 }))).toEqual({
|
||||
status: "completed",
|
||||
output: { url, contentType: "text/plain", format: "text", output: "hello" },
|
||||
|
||||
@@ -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,25 +146,35 @@ 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"])
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["websearch"])
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -118,7 +118,7 @@ describe("WriteTool", () => {
|
||||
reset()
|
||||
return withTool(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["write", "execute"])
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["write"])
|
||||
const settled = yield* executeTool(registry, call({ path: "src/new.txt", content: "created" }))
|
||||
expect(settled).toEqual({
|
||||
status: "completed",
|
||||
|
||||
@@ -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" })
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user