mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-07 01:16:24 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cd2559b24 | ||
|
|
a02415a3eb | ||
|
|
beca5e8926 | ||
|
|
e3c89654d1 | ||
|
|
33f48f36c9 | ||
|
|
58f949d2d0 | ||
|
|
2a895b9e03 | ||
|
|
fe506f201d | ||
|
|
b2cecc6350 | ||
|
|
63a1074c6c | ||
|
|
31ee07e3ae | ||
|
|
370b9965d3 | ||
|
|
8ea99ef9ad |
@@ -415,10 +415,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
|
||||
// System prompts share the cache-point convention: emit the text block, then
|
||||
// optionally a positional `cachePoint` marker.
|
||||
const lowerSystem = (
|
||||
breakpoints: BedrockCache.Breakpoints,
|
||||
system: ReadonlyArray<LLMRequest["system"][number]>,
|
||||
) => {
|
||||
const lowerSystem = (breakpoints: BedrockCache.Breakpoints, system: ReadonlyArray<LLMRequest["system"][number]>) => {
|
||||
const content = system
|
||||
.filter((part) => part.text.length > 0)
|
||||
.flatMap((part) => textWithCache(breakpoints, part.text, part.cache))
|
||||
@@ -431,7 +428,7 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
|
||||
const generation = request.generation
|
||||
// Bedrock-Claude shares Anthropic's 4-breakpoint cap. Spend the budget in
|
||||
// tools → system → messages order to favour the highest-impact prefixes.
|
||||
const breakpoints = BedrockCache.breakpoints()
|
||||
const breakpoints = BedrockCache.breakpoints(request.model.id)
|
||||
const toolConfig = (() => {
|
||||
if (flattened.tools.length === 0) return undefined
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import type { CacheHint } from "../../schema/index.js"
|
||||
import { newBreakpoints, ttlBucket, type Breakpoints } from "./cache.js"
|
||||
import { newBreakpoints, ttlBucket } from "./cache.js"
|
||||
|
||||
// Bedrock cache markers are positional: emit a `cachePoint` block immediately
|
||||
// after the content the caller wants treated as a cacheable prefix. Bedrock
|
||||
@@ -13,24 +13,46 @@ export const CachePointBlock = Schema.Struct({
|
||||
})
|
||||
export type CachePointBlock = Schema.Schema.Type<typeof CachePointBlock>
|
||||
|
||||
// Callers pass a shared counter through every `block()` call site so the
|
||||
// four-breakpoint budget is respected across `system`, `messages`, and `tools`.
|
||||
const LEGACY_CLAUDE = ["anthropic.claude-instant", "anthropic.claude-v1", "anthropic.claude-v2", "anthropic.claude-3-"]
|
||||
|
||||
// These legacy Claude releases support explicit caching, but only for five minutes.
|
||||
const CLAUDE_5M = [
|
||||
"anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
"anthropic.claude-3-5-haiku-20241022-v1:0",
|
||||
"anthropic.claude-3-7-sonnet-20250219-v1:0",
|
||||
"anthropic.claude-sonnet-4-20250514-v1:0",
|
||||
"anthropic.claude-opus-4-20250514-v1:0",
|
||||
"anthropic.claude-opus-4-1-20250805-v1:0",
|
||||
]
|
||||
|
||||
// Callers share the four-breakpoint budget across system, messages, and tools.
|
||||
export const BEDROCK_BREAKPOINT_CAP = 4
|
||||
|
||||
export type { Breakpoints } from "./cache.js"
|
||||
export const breakpoints = () => newBreakpoints(BEDROCK_BREAKPOINT_CAP)
|
||||
export const breakpoints = (modelID: string) => {
|
||||
// Substring matching also handles regional prefixes and model-bearing ARNs.
|
||||
const short = CLAUDE_5M.some((id) => modelID.includes(id))
|
||||
return {
|
||||
...newBreakpoints(BEDROCK_BREAKPOINT_CAP),
|
||||
// Assume modern Claude releases retain caching support; older generations need an explicit exception.
|
||||
// Other model families use implicit caching where available.
|
||||
supported: modelID.includes("anthropic.claude-") && (short || !LEGACY_CLAUDE.some((id) => modelID.includes(id))),
|
||||
ttl1h: !short,
|
||||
}
|
||||
}
|
||||
export type Breakpoints = ReturnType<typeof breakpoints>
|
||||
|
||||
const DEFAULT_5M: CachePointBlock = { cachePoint: { type: "default" } }
|
||||
const DEFAULT_1H: CachePointBlock = { cachePoint: { type: "default", ttl: "1h" } }
|
||||
|
||||
export const block = (breakpoints: Breakpoints, cache: CacheHint | undefined): CachePointBlock | undefined => {
|
||||
if (!breakpoints.supported) return undefined
|
||||
if (cache?.type !== "ephemeral" && cache?.type !== "persistent") return undefined
|
||||
if (breakpoints.remaining <= 0) {
|
||||
breakpoints.dropped += 1
|
||||
return undefined
|
||||
}
|
||||
breakpoints.remaining -= 1
|
||||
return ttlBucket(cache.ttlSeconds) === "1h" ? DEFAULT_1H : DEFAULT_5M
|
||||
return breakpoints.ttl1h && ttlBucket(cache.ttlSeconds) === "1h" ? DEFAULT_1H : DEFAULT_5M
|
||||
}
|
||||
|
||||
export * as BedrockCache from "./bedrock-cache.js"
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { CacheHint, LLM, Message, ToolCallPart } from "../../src/index.js"
|
||||
import { AmazonBedrock } from "../../src/providers.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
|
||||
const bedrock = AmazonBedrock.configure({ apiKey: "fixture" })
|
||||
|
||||
describe("Bedrock Converse cache policy", () => {
|
||||
for (const id of [
|
||||
"deepseek.r1-v1:0",
|
||||
"meta.llama3-3-70b-instruct-v1:0",
|
||||
"mistral.mistral-large-2402-v1:0",
|
||||
"qwen.qwen3-coder-480b-a35b-v1:0",
|
||||
"openai.gpt-oss-120b-1:0",
|
||||
"cohere.command-r-v1:0",
|
||||
"anthropic.claude-instant-v1",
|
||||
"anthropic.claude-v1",
|
||||
"anthropic.claude-v2",
|
||||
"anthropic.claude-v2:1",
|
||||
"anthropic.claude-3-haiku-20240307-v1:0",
|
||||
"anthropic.claude-3-sonnet-20240229-v1:0",
|
||||
"anthropic.claude-3-opus-20240229-v1:0",
|
||||
"anthropic.claude-3-5-sonnet-20240620-v1:0",
|
||||
"amazon.nova-lite-v1:0",
|
||||
"global.amazon.nova-2-lite-v1:0",
|
||||
"custom-model",
|
||||
"arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123",
|
||||
]) {
|
||||
for (const policy of [undefined, "auto", "none", { tools: true, system: true, messages: { tail: 3 } }] as const) {
|
||||
it.effect(`omits checkpoints for ${id} (${JSON.stringify(policy)})`, () =>
|
||||
Effect.gen(function* () {
|
||||
// Exercise both automatic placement and manual hints at every lowering site.
|
||||
const cache = policy === "none" ? new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }) : undefined
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: bedrock.model(id),
|
||||
cache: policy,
|
||||
system: [{ type: "text", text: "System prefix", cache }],
|
||||
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" }, cache }],
|
||||
messages: [
|
||||
Message.user([{ type: "text", text: "Question", cache }]),
|
||||
Message.system([{ type: "text", text: "Update", cache }]),
|
||||
Message.assistant([
|
||||
{ type: "text", text: "Answer", cache },
|
||||
{ type: "reasoning", text: "Unsigned reasoning", cache },
|
||||
ToolCallPart.make({ id: "call_1", name: "lookup", input: {} }),
|
||||
]),
|
||||
Message.tool({ id: "call_1", name: "lookup", result: "Result", cache }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(JSON.stringify(prepared.body)).not.toContain("cachePoint")
|
||||
expect(prepared.body).toMatchObject({
|
||||
modelId: id,
|
||||
system: [{ text: "System prefix" }],
|
||||
toolConfig: { tools: [{ toolSpec: { name: "lookup" } }] },
|
||||
messages: [
|
||||
{ role: "user", content: [{ text: "Question" }, { text: "<system-update>\nUpdate\n</system-update>" }] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ text: "Answer" }, { text: "Unsigned reasoning" }, { toolUse: { name: "lookup" } }],
|
||||
},
|
||||
{ role: "user", content: [{ toolResult: { content: [{ json: "Result" }] } }] },
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, ttl] of [
|
||||
["anthropic.claude-3-5-sonnet-20241022-v2:0", undefined],
|
||||
["us.anthropic.claude-3-5-haiku-20241022-v1:0", undefined],
|
||||
["eu.anthropic.claude-3-7-sonnet-20250219-v1:0", undefined],
|
||||
["apac.anthropic.claude-sonnet-4-20250514-v1:0", undefined],
|
||||
["anthropic.claude-opus-4-20250514-v1:0", undefined],
|
||||
["anthropic.claude-opus-4-1-20250805-v1:0", undefined],
|
||||
["anthropic.claude-sonnet-4-5-20250929-v1:0", "1h"],
|
||||
["global.anthropic.claude-sonnet-99", "1h"],
|
||||
["anthropic.claude-new-family-99", "1h"],
|
||||
["arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-6", "1h"],
|
||||
["arn:aws:bedrock:us-east-1:123456789012:inference-profile/us.anthropic.claude-sonnet-4-6", "1h"],
|
||||
] as const) {
|
||||
for (const ttlSeconds of [undefined, 3600]) {
|
||||
it.effect(`preserves Claude checkpoints for ${id} (TTL: ${ttlSeconds ?? "default"})`, () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: bedrock.model(id),
|
||||
system: [
|
||||
{ type: "text", text: "Agent" },
|
||||
{ type: "text", text: "Project" },
|
||||
],
|
||||
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" } }],
|
||||
prompt: "Question",
|
||||
cache:
|
||||
ttlSeconds === undefined ? undefined : { tools: true, system: true, messages: { tail: 1 }, ttlSeconds },
|
||||
}),
|
||||
)
|
||||
const marker = {
|
||||
cachePoint: ttlSeconds === undefined || ttl === undefined ? { type: "default" } : { type: "default", ttl },
|
||||
}
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
modelId: id,
|
||||
toolConfig: { tools: [{ toolSpec: { name: "lookup" } }, marker] },
|
||||
system: [{ text: "Agent" }, marker, { text: "Project" }, marker],
|
||||
messages: [{ role: "user", content: [{ text: "Question" }, marker] }],
|
||||
})
|
||||
if (ttlSeconds === undefined || ttl === undefined)
|
||||
expect(JSON.stringify(prepared.body)).not.toContain('"ttl"')
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -136,7 +136,7 @@ const captureHeaders = (target: LanguageModel) =>
|
||||
const model = AmazonBedrock.configure({
|
||||
baseURL: "https://bedrock-runtime.test",
|
||||
apiKey: "test-bearer",
|
||||
}).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
|
||||
}).model("anthropic.claude-sonnet-4-5-20250929-v1:0")
|
||||
|
||||
const baseRequest = LLM.request({
|
||||
id: "req_1",
|
||||
@@ -155,7 +155,7 @@ describe("Bedrock Converse route", () => {
|
||||
const prepared = yield* compileRequest(baseRequest)
|
||||
|
||||
expect(prepared.body).toEqual({
|
||||
modelId: "anthropic.claude-3-5-sonnet-20240620-v1:0",
|
||||
modelId: "anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
system: [{ text: "You are concise." }],
|
||||
messages: [{ role: "user", content: [{ text: "Say hello." }] }],
|
||||
inferenceConfig: { maxTokens: 64, temperature: 0 },
|
||||
|
||||
@@ -43,6 +43,7 @@ The suite contains:
|
||||
- home-session click timing split between content and titlebar-tab paint
|
||||
- single-session tab close timing through stable home restoration
|
||||
- cached session repaint and mutation tracing
|
||||
- large-session search scan, first-result reveal, and highlight stabilization
|
||||
- streaming timeline throughput, RAF-gap, long-task, geometry, and remount diagnostics
|
||||
- retained renderer heap with a large model catalog across repeated session navigation
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { benchmark, expect } from "../benchmark"
|
||||
import { buildInitialStreamEvent, setupTimelineBenchmark, textPartID } from "./session-timeline-benchmark.fixture"
|
||||
import {
|
||||
collectTimelineSearchMetrics,
|
||||
installTimelineSearchProbe,
|
||||
waitForStableTimelineSearch,
|
||||
} from "./session-timeline-search-probe"
|
||||
|
||||
benchmark("searches a large virtualized session and reveals the first result", async ({ page, report }) => {
|
||||
benchmark.setTimeout(180_000)
|
||||
const historyTurns = Number(process.env.TIMELINE_SEARCH_HISTORY_TURNS ?? 320)
|
||||
const completionTimeout = Number(process.env.TIMELINE_SEARCH_COMPLETION_TIMEOUT_MS ?? 60_000)
|
||||
const query = "Historical prompt"
|
||||
const targetPartID = "msg_0000_0000_a_user:text:0"
|
||||
const expectedCounter = `1/${historyTurns}`
|
||||
const fixture = await setupTimelineBenchmark(page, {
|
||||
historyTurns,
|
||||
eventBatch: 1,
|
||||
})
|
||||
|
||||
fixture.transport.enqueue(buildInitialStreamEvent(1))
|
||||
await expect(fixture.text).toContainText("Implementation plan")
|
||||
await fixture.scrollToBottom()
|
||||
await fixture.waitForStableGeometry()
|
||||
// Chromium reserves the physical shortcut for its native find overlay, so request the same controller path directly.
|
||||
await page.evaluate(() => document.dispatchEvent(new Event("opencode:timeline-search-open")))
|
||||
|
||||
const search = page.locator('[data-component="timeline-search-bar"]')
|
||||
const field = search.getByRole("searchbox", { name: "Find..." })
|
||||
const count = search.locator('[data-slot="timeline-search-count"]')
|
||||
const target = page.locator(`[data-timeline-part-id="${targetPartID}"]`)
|
||||
await expect(field).toBeVisible()
|
||||
await expect(field).toBeFocused()
|
||||
await installTimelineSearchProbe(page, { targetPartID })
|
||||
|
||||
await field.fill(query)
|
||||
await expect(count).toHaveText(expectedCounter)
|
||||
await expect(target).toBeVisible({ timeout: completionTimeout })
|
||||
await waitForStableTimelineSearch(page, { counter: expectedCounter, targetPartID, timeout: completionTimeout })
|
||||
const metrics = await collectTimelineSearchMetrics(page, { counter: expectedCounter, targetPartID })
|
||||
|
||||
expect(metrics.summary.handlerDurationMs).toBeDefined()
|
||||
expect(metrics.summary.firstCountObservedMs).toBeDefined()
|
||||
expect(metrics.summary.firstTargetVisibleMs).toBeDefined()
|
||||
expect(metrics.summary.firstActiveHighlightObservedMs).toBeDefined()
|
||||
expect(metrics.summary.stableResultObservedMs).toBeDefined()
|
||||
expect(metrics.summary.activeHighlightRanges).toBe(1)
|
||||
report(metrics, { historyTurns, query, expectedMatches: historyTurns })
|
||||
|
||||
// Check navigation and the V2 assistant content IDs outside the measured interval.
|
||||
await field.press("Enter")
|
||||
await expect(count).toHaveText(`2/${historyTurns}`)
|
||||
await field.press("Shift+Enter")
|
||||
await expect(count).toHaveText(expectedCounter)
|
||||
await field.fill("Implementation plan")
|
||||
await expect(count).toHaveText("1/1")
|
||||
await expect(fixture.text).toBeInViewport()
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const range = [...(CSS.highlights.get("timeline-search-hit-active") ?? [])][0]
|
||||
return range?.startContainer.parentElement?.closest<HTMLElement>("[data-timeline-part-id]")?.dataset
|
||||
.timelinePartId
|
||||
}),
|
||||
)
|
||||
.toBe(textPartID)
|
||||
await field.press("Escape")
|
||||
await expect(search).toBeHidden()
|
||||
})
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
|
||||
export type TimelineSearchSample = {
|
||||
observedAtMs: number
|
||||
counter: string
|
||||
targetMounted: boolean
|
||||
targetVisible: boolean
|
||||
targetTopPx?: number
|
||||
activeRanges: number
|
||||
activePartID?: string
|
||||
activeVisible: boolean
|
||||
scrollTopPx: number
|
||||
}
|
||||
|
||||
type TimelineSearchProbe = {
|
||||
samples: TimelineSearchSample[]
|
||||
handlerDurationMs?: number
|
||||
initialScrollTopPx: number
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
export async function installTimelineSearchProbe(page: Page, input: { targetPartID: string }) {
|
||||
await page.evaluate(({ targetPartID }) => {
|
||||
const search = document.querySelector<HTMLElement>('[data-component="timeline-search-bar"]')
|
||||
const field = search?.querySelector<HTMLInputElement>('[data-slot="text-input-v2-input"]')
|
||||
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
|
||||
element.querySelector("[data-timeline-row]"),
|
||||
)
|
||||
if (!search || !field || !root) throw new Error("missing timeline search benchmark nodes")
|
||||
|
||||
const samples: TimelineSearchSample[] = []
|
||||
const initialScrollTopPx = root.scrollTop
|
||||
let startedAt: number | undefined
|
||||
let handlerDurationMs: number | undefined
|
||||
let frame: number | undefined
|
||||
let running = true
|
||||
|
||||
const visibleInRoot = (rect: DOMRect) => {
|
||||
const viewport = root.getBoundingClientRect()
|
||||
return rect.width > 0 && rect.height > 0 && rect.bottom > viewport.top && rect.top < viewport.bottom
|
||||
}
|
||||
const sample = () => {
|
||||
if (!running || startedAt === undefined) return
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = undefined
|
||||
setTimeout(() => {
|
||||
if (!running || startedAt === undefined) return
|
||||
const target = root.querySelector<HTMLElement>(`[data-timeline-part-id="${targetPartID}"]`)
|
||||
const targetRect = target?.getBoundingClientRect()
|
||||
const highlight = CSS.highlights.get("timeline-search-hit-active")
|
||||
const ranges = highlight ? [...highlight] : []
|
||||
const active = ranges.find((range): range is Range => range instanceof Range)
|
||||
const activeRect = active?.getBoundingClientRect()
|
||||
const activeElement =
|
||||
active?.startContainer instanceof Element ? active.startContainer : active?.startContainer.parentElement
|
||||
|
||||
samples.push({
|
||||
observedAtMs: performance.now() - startedAt,
|
||||
counter:
|
||||
search.querySelector<HTMLElement>('[data-slot="timeline-search-count"]')?.textContent?.trim() ?? "",
|
||||
targetMounted: !!target,
|
||||
targetVisible: !!targetRect && visibleInRoot(targetRect),
|
||||
targetTopPx: targetRect?.top,
|
||||
activeRanges: ranges.length,
|
||||
activePartID: activeElement?.closest<HTMLElement>("[data-timeline-part-id]")?.dataset.timelinePartId,
|
||||
activeVisible: !!activeRect && visibleInRoot(activeRect),
|
||||
scrollTopPx: root.scrollTop,
|
||||
})
|
||||
sample()
|
||||
}, 0)
|
||||
})
|
||||
}
|
||||
const onInputCapture = (event: Event) => {
|
||||
if (event.target !== field || startedAt !== undefined) return
|
||||
startedAt = performance.now()
|
||||
sample()
|
||||
}
|
||||
const onInput = (event: Event) => {
|
||||
if (event.target !== field || startedAt === undefined || handlerDurationMs !== undefined) return
|
||||
handlerDurationMs = performance.now() - startedAt
|
||||
}
|
||||
document.addEventListener("input", onInputCapture, { capture: true })
|
||||
document.addEventListener("input", onInput)
|
||||
;(window as Window & { __timelineSearchBenchmark?: TimelineSearchProbe }).__timelineSearchBenchmark = {
|
||||
samples,
|
||||
initialScrollTopPx,
|
||||
get handlerDurationMs() {
|
||||
return handlerDurationMs
|
||||
},
|
||||
stop: () => {
|
||||
running = false
|
||||
document.removeEventListener("input", onInputCapture, { capture: true })
|
||||
document.removeEventListener("input", onInput)
|
||||
if (frame !== undefined) cancelAnimationFrame(frame)
|
||||
},
|
||||
}
|
||||
}, input)
|
||||
}
|
||||
|
||||
export async function waitForStableTimelineSearch(
|
||||
page: Page,
|
||||
input: { counter: string; targetPartID: string; timeout: number },
|
||||
) {
|
||||
await page.waitForFunction(
|
||||
({ counter, targetPartID }) => {
|
||||
const samples = (window as Window & { __timelineSearchBenchmark?: TimelineSearchProbe }).__timelineSearchBenchmark
|
||||
?.samples
|
||||
if (!samples) return false
|
||||
return samples.some((_, index) => {
|
||||
const stable = samples.slice(index, index + 3)
|
||||
if (stable.length !== 3) return false
|
||||
return stable.every(
|
||||
(sample, sampleIndex) =>
|
||||
sample.counter === counter &&
|
||||
sample.targetVisible &&
|
||||
sample.activeRanges === 1 &&
|
||||
sample.activePartID === targetPartID &&
|
||||
sample.activeVisible &&
|
||||
(sampleIndex === 0 ||
|
||||
(Math.abs(sample.scrollTopPx - stable[sampleIndex - 1]!.scrollTopPx) <= 1 &&
|
||||
Math.abs((sample.targetTopPx ?? Infinity) - (stable[sampleIndex - 1]!.targetTopPx ?? -Infinity)) <= 1)),
|
||||
)
|
||||
})
|
||||
},
|
||||
{ counter: input.counter, targetPartID: input.targetPartID },
|
||||
{ timeout: input.timeout },
|
||||
)
|
||||
}
|
||||
|
||||
export async function collectTimelineSearchMetrics(page: Page, input: { counter: string; targetPartID: string }) {
|
||||
const result = await page.evaluate(() => {
|
||||
const probe = (window as Window & { __timelineSearchBenchmark?: TimelineSearchProbe }).__timelineSearchBenchmark
|
||||
if (!probe) throw new Error("missing timeline search benchmark probe")
|
||||
probe.stop()
|
||||
return {
|
||||
samples: probe.samples,
|
||||
handlerDurationMs: probe.handlerDurationMs,
|
||||
initialScrollTopPx: probe.initialScrollTopPx,
|
||||
}
|
||||
})
|
||||
const first = (predicate: (sample: TimelineSearchSample) => boolean) => result.samples.find(predicate)?.observedAtMs
|
||||
const stable = result.samples.findIndex((_, index) => {
|
||||
const samples = result.samples.slice(index, index + 3)
|
||||
if (samples.length !== 3) return false
|
||||
return samples.every(
|
||||
(sample, sampleIndex) =>
|
||||
sample.counter === input.counter &&
|
||||
sample.targetVisible &&
|
||||
sample.activeRanges === 1 &&
|
||||
sample.activePartID === input.targetPartID &&
|
||||
sample.activeVisible &&
|
||||
(sampleIndex === 0 ||
|
||||
(Math.abs(sample.scrollTopPx - samples[sampleIndex - 1]!.scrollTopPx) <= 1 &&
|
||||
Math.abs((sample.targetTopPx ?? Infinity) - (samples[sampleIndex - 1]!.targetTopPx ?? -Infinity)) <= 1)),
|
||||
)
|
||||
})
|
||||
const final = result.samples.at(-1)
|
||||
|
||||
return {
|
||||
summary: {
|
||||
handlerDurationMs: result.handlerDurationMs,
|
||||
firstCountObservedMs: first((sample) => sample.counter === input.counter),
|
||||
firstTargetMountedMs: first((sample) => sample.targetMounted),
|
||||
firstTargetVisibleMs: first((sample) => sample.targetVisible),
|
||||
firstActiveHighlightObservedMs: first(
|
||||
(sample) => sample.activeRanges === 1 && sample.activePartID === input.targetPartID && sample.activeVisible,
|
||||
),
|
||||
stableResultObservedMs: stable >= 0 ? result.samples[stable + 2]?.observedAtMs : undefined,
|
||||
initialScrollTopPx: result.initialScrollTopPx,
|
||||
finalScrollTopPx: final?.scrollTopPx,
|
||||
scrollDistancePx: final === undefined ? undefined : Math.abs(result.initialScrollTopPx - final.scrollTopPx),
|
||||
activeHighlightRanges: final?.activeRanges,
|
||||
},
|
||||
samples: result.samples,
|
||||
}
|
||||
}
|
||||
@@ -165,6 +165,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
::highlight(timeline-search-hit) {
|
||||
background-color: color-mix(in srgb, var(--v2-icon-icon-accent) 28%, transparent);
|
||||
}
|
||||
|
||||
::highlight(timeline-search-hit-active) {
|
||||
background-color: var(--v2-icon-icon-accent);
|
||||
color: var(--v2-background-bg-deep);
|
||||
}
|
||||
|
||||
[data-component="getting-started"] {
|
||||
container-type: inline-size;
|
||||
container-name: getting-started;
|
||||
|
||||
@@ -769,6 +769,8 @@ export const dict = {
|
||||
|
||||
"session.header.search.placeholder": "Search {{project}}",
|
||||
"session.header.searchFiles": "Search files",
|
||||
"session.search.placeholder": "Find...",
|
||||
"session.search.noResults": "No matches",
|
||||
"session.header.openIn": "Open in",
|
||||
"session.header.open.action": "Open {{app}}",
|
||||
"session.header.open.ariaLabel": "Open in {{app}}",
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createRequestQueue } from "./request-queue"
|
||||
import { createRequestQueue, isSlowRequest } from "./request-queue"
|
||||
|
||||
function setup(input?: { limit?: number; stallMs?: number; headersTimeoutMs?: number }) {
|
||||
function setup(input?: { limit?: number; slowLimit?: number; stallMs?: number; headersTimeoutMs?: number }) {
|
||||
const pending: Array<{ url: string; signal: AbortSignal; resolve: () => void }> = []
|
||||
const logs: Array<{ message: string; data: Record<string, unknown> }> = []
|
||||
let clock = 0
|
||||
const queue = createRequestQueue({
|
||||
limit: input?.limit ?? 2,
|
||||
slowLimit: input?.slowLimit,
|
||||
stallMs: input?.stallMs,
|
||||
headersTimeoutMs: input?.headersTimeoutMs,
|
||||
now: () => clock,
|
||||
@@ -40,6 +41,36 @@ describe("createRequestQueue", () => {
|
||||
expect(input.queue.inflight()).toBe(0)
|
||||
})
|
||||
|
||||
test("slow endpoints hold at most their share of slots so small reads go first", async () => {
|
||||
const input = setup({ limit: 4, slowLimit: 2 })
|
||||
const paths = ["/api/vcs?location[directory]=%2Fa", "/api/vcs/diff?location[directory]=%2Fa", "/api/worktree", "/api/session/ses_1"]
|
||||
const responses = paths.map((path) => input.queue.fetch(`http://server${path}`))
|
||||
await input.settle()
|
||||
const started = () => input.pending.map((item) => new URL(item.url).pathname)
|
||||
// Two slow requests fill the slow share; the worktree read waits while the session read jumps ahead.
|
||||
expect(started()).toEqual(["/api/vcs", "/api/vcs/diff", "/api/session/ses_1"])
|
||||
expect(input.queue.inflight()).toBe(3)
|
||||
expect(input.queue.queued()).toBe(1)
|
||||
// A fast request finishing does not free a slow slot.
|
||||
input.pending[2]!.resolve()
|
||||
await input.settle()
|
||||
expect(started()).toEqual(["/api/vcs", "/api/vcs/diff", "/api/session/ses_1"])
|
||||
input.pending[0]!.resolve()
|
||||
await input.settle()
|
||||
expect(started()).toEqual(["/api/vcs", "/api/vcs/diff", "/api/session/ses_1", "/api/worktree"])
|
||||
input.pending.forEach((item) => item.resolve())
|
||||
await Promise.all(responses)
|
||||
expect(input.queue.inflight()).toBe(0)
|
||||
})
|
||||
|
||||
test("classifies git and worktree endpoints as slow", () => {
|
||||
expect(isSlowRequest("/api/vcs")).toBe(true)
|
||||
expect(isSlowRequest("/api/vcs/branches")).toBe(true)
|
||||
expect(isSlowRequest("/api/worktree")).toBe(true)
|
||||
expect(isSlowRequest("/api/vcsx")).toBe(false)
|
||||
expect(isSlowRequest("/api/session")).toBe(false)
|
||||
})
|
||||
|
||||
test("never counts the event stream against the budget", async () => {
|
||||
const input = setup({ limit: 1 })
|
||||
void input.queue.fetch("http://server/api/session")
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
type Entry = { method: string; url: string; at: number }
|
||||
type Entry = { method: string; url: string; at: number; slow: boolean }
|
||||
|
||||
// Chromium allows six connections per origin. The event stream holds one for the life of the
|
||||
// connection and health probes use their own fetch, so the app's API calls stay below that or
|
||||
// a burst stalls probes and user actions inside the browser where nothing can observe it.
|
||||
export const requestQueueLimit = 4
|
||||
|
||||
// Endpoints that shell out to git or walk the filesystem take seconds on a large repository. They
|
||||
// may hold at most this many slots, so a session mount's small reads never queue behind them.
|
||||
export const requestQueueSlowLimit = 2
|
||||
export const slowRequestPaths = ["/api/vcs", "/api/worktree"]
|
||||
|
||||
// A mount legitimately fires a dozen requests at once; only a request that has waited this long
|
||||
// for a slot indicates the server is not keeping up.
|
||||
export const requestStallMs = 2_000
|
||||
@@ -14,15 +19,21 @@ export const requestStallMs = 2_000
|
||||
// instead of wedging every later API call; the body may still stream for as long as it needs.
|
||||
export const requestHeadersTimeoutMs = 60_000
|
||||
|
||||
export function isSlowRequest(pathname: string) {
|
||||
return slowRequestPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`))
|
||||
}
|
||||
|
||||
export function createRequestQueue(input: {
|
||||
fetch: typeof globalThis.fetch
|
||||
limit?: number
|
||||
slowLimit?: number
|
||||
stallMs?: number
|
||||
headersTimeoutMs?: number
|
||||
log?: (message: string, data: Record<string, unknown>) => void
|
||||
now?: () => number
|
||||
}) {
|
||||
const limit = input.limit ?? requestQueueLimit
|
||||
const slowLimit = input.slowLimit ?? requestQueueSlowLimit
|
||||
const stallMs = input.stallMs ?? requestStallMs
|
||||
const headersTimeoutMs = input.headersTimeoutMs ?? requestHeadersTimeoutMs
|
||||
// Call the browser fetch unbound; `input.fetch(...)` would make `this` the options object.
|
||||
@@ -50,9 +61,17 @@ export function createRequestQueue(input: {
|
||||
}
|
||||
watcher = setTimeout(watch, stallMs)
|
||||
}
|
||||
const canStart = (entry: Entry) => {
|
||||
if (inflight.size >= limit) return false
|
||||
if (!entry.slow) return true
|
||||
return [...inflight].filter((item) => item.slow).length < slowLimit
|
||||
}
|
||||
// FIFO, except a slow request waits its turn behind faster ones while the slow slots are full.
|
||||
const release = (entry: Entry) => {
|
||||
inflight.delete(entry)
|
||||
waiting.shift()?.start()
|
||||
const index = waiting.findIndex((item) => canStart(item.entry))
|
||||
if (index === -1) return
|
||||
waiting.splice(index, 1)[0]?.start()
|
||||
}
|
||||
const acquire = (entry: Entry) =>
|
||||
new Promise<void>((resolve) => {
|
||||
@@ -61,7 +80,7 @@ export function createRequestQueue(input: {
|
||||
inflight.add(entry)
|
||||
resolve()
|
||||
}
|
||||
if (inflight.size < limit) return start()
|
||||
if (canStart(entry)) return start()
|
||||
waiting.push({ entry, start })
|
||||
watcher ??= setTimeout(watch, stallMs)
|
||||
})
|
||||
@@ -69,9 +88,10 @@ export function createRequestQueue(input: {
|
||||
const fetch: typeof globalThis.fetch = Object.assign(
|
||||
async (resource: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = new Request(resource, init)
|
||||
const pathname = new URL(request.url).pathname
|
||||
// The event stream is long-lived; never count it against the request budget.
|
||||
if (new URL(request.url).pathname === "/api/event") return base(request)
|
||||
const entry = { method: request.method, url: request.url, at: now() }
|
||||
if (pathname === "/api/event") return base(request)
|
||||
const entry = { method: request.method, url: request.url, at: now(), slow: isSlowRequest(pathname) }
|
||||
await acquire(entry)
|
||||
if (request.signal.aborted) {
|
||||
release(entry)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createRoot, createSignal } from "solid-js"
|
||||
import { createConnectionSync } from "./connection"
|
||||
import { createConnectionSync, reconnectOrder } from "./connection"
|
||||
|
||||
test("invalidates disconnected data and synchronizes after the handshake", () => {
|
||||
const calls: string[] = []
|
||||
@@ -19,3 +19,9 @@ test("invalidates disconnected data and synchronizes after the handshake", () =>
|
||||
})
|
||||
dispose()
|
||||
})
|
||||
|
||||
test("held directories refresh before the rest, otherwise keeping their order", () => {
|
||||
const held = new Set(["/b", "/d"])
|
||||
expect(reconnectOrder(["/a", "/b", "/c", "/d"], (directory) => held.has(directory))).toEqual(["/b", "/d", "/a", "/c"])
|
||||
expect(reconnectOrder(["/a", "/c"], (directory) => held.has(directory))).toEqual(["/a", "/c"])
|
||||
})
|
||||
|
||||
@@ -20,3 +20,8 @@ export function createConnectionSync(input: {
|
||||
|
||||
return { handleEvent }
|
||||
}
|
||||
|
||||
// Directories a mounted view holds refresh first; the rest keep their existing order behind them.
|
||||
export function reconnectOrder(directories: string[], held: (directory: string) => boolean) {
|
||||
return [...directories.filter(held), ...directories.filter((directory) => !held(directory))]
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import type { ServerScope } from "@/runtime/server/scope"
|
||||
import { persisted } from "@/runtime/persistence/storage"
|
||||
import type { ServerApi } from "@/runtime/server/api"
|
||||
import { toggleMcp } from "./global-sync/mcp"
|
||||
import { createConnectionSync } from "./server-sync/connection"
|
||||
import { createConnectionSync, reconnectOrder } from "./server-sync/connection"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import type { Data } from "@opencode-ai/client/solid"
|
||||
import { createWorktreeInventory, withWorktreeInventory } from "@/workspaces/inventory"
|
||||
@@ -162,12 +162,11 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
},
|
||||
connected: (info) => {
|
||||
if (bootstrap.data !== undefined && !bootstrap.isFetching) void bootstrap.refetch()
|
||||
Object.keys(children.children)
|
||||
.filter(children.active)
|
||||
.forEach((directory) => {
|
||||
queue.push(directory)
|
||||
void data.location.sync({ directory }).catch(() => undefined)
|
||||
})
|
||||
// The refresh queue re-syncs two directories at a time, held ones first. Syncing every active
|
||||
// directory here as well sent the whole catalog fan-out for all of them at once.
|
||||
reconnectOrder(Object.keys(children.children).filter(children.active), children.pinned).forEach(
|
||||
(directory) => queue.push(directory),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ import { createSessionReview } from "./review/model"
|
||||
import { SessionDesktopReview, SessionMobileReview, SessionMobileViewTabs } from "./review/view"
|
||||
import { SessionContextTab } from "./files/session-context-tab"
|
||||
import { createSessionTimelineInteraction } from "./timeline/interaction"
|
||||
import { createTimelineSearchController } from "./timeline/search-controller"
|
||||
import { TimelineSearchBar } from "./timeline/search-bar"
|
||||
import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./composer/region"
|
||||
import { SessionIdentityHeader } from "./session-identity-header"
|
||||
import { SessionReviewToggle } from "./header/session-header-actions"
|
||||
@@ -47,6 +49,12 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
const isDesktop = session.isDesktop
|
||||
const screen = createSessionScreenLayout(session)
|
||||
const timeline = createSessionTimelineInteraction(session)
|
||||
const timelineSearch = createTimelineSearchController({
|
||||
sessionID: session.identity.sessionID,
|
||||
scrollRef: timeline.scroller,
|
||||
revealMessage: timeline.actions.revealMessage,
|
||||
pauseAutoScroll: timeline.view.unpin,
|
||||
})
|
||||
const messagesReady = timeline.ready
|
||||
const [store, setStore] = createStore({
|
||||
deferRender: false,
|
||||
@@ -262,6 +270,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
anchor={timeline.view.anchor}
|
||||
setRevealMessage={timeline.view.setRevealMessage}
|
||||
setScrollToEnd={timeline.view.setScrollToEnd}
|
||||
search={<TimelineSearchBar controller={timelineSearch} />}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
@@ -24,6 +24,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
pinned: true,
|
||||
},
|
||||
refs: {
|
||||
scroller: undefined as HTMLDivElement | undefined,
|
||||
content: undefined as HTMLDivElement | undefined,
|
||||
dock: undefined as HTMLDivElement | undefined,
|
||||
},
|
||||
@@ -38,7 +39,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
}
|
||||
let scroller: HTMLDivElement | undefined
|
||||
let dockHeight = 0
|
||||
let revealMessage = (_id: string) => {}
|
||||
let revealMessage = (_id: string, _partID?: string) => {}
|
||||
let scrollToEnd = () => {}
|
||||
let scrollMark = 0
|
||||
let messageMark = 0
|
||||
@@ -157,6 +158,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
}
|
||||
const setScrollRef = (element: HTMLDivElement | undefined) => {
|
||||
scroller = element
|
||||
setState("refs", "scroller", element)
|
||||
if (!element) return
|
||||
scheduleScrollState(element)
|
||||
fill()
|
||||
@@ -290,6 +292,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
return {
|
||||
actions: {
|
||||
navigateMessage,
|
||||
revealMessage: (id: string, partID?: string) => revealMessage(id, partID),
|
||||
resume,
|
||||
setActiveMessage,
|
||||
},
|
||||
@@ -297,7 +300,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
resource: timeline.resource,
|
||||
ready: timeline.ready,
|
||||
scroll: state.scroll,
|
||||
scroller: () => scroller,
|
||||
scroller: () => state.refs.scroller,
|
||||
view: {
|
||||
anchor,
|
||||
markUserScroll,
|
||||
@@ -313,7 +316,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
setDockRef: (element: HTMLDivElement | undefined) => {
|
||||
setState("refs", "dock", element)
|
||||
},
|
||||
setRevealMessage: (reveal: (id: string) => void) => {
|
||||
setRevealMessage: (reveal: (id: string, partID?: string) => void) => {
|
||||
revealMessage = reveal
|
||||
},
|
||||
setScrollRef,
|
||||
|
||||
@@ -353,8 +353,9 @@ type MessageTimelineProps = {
|
||||
workspaceMoveEligible: boolean
|
||||
onSummaryOpenChange: (open: boolean) => void
|
||||
anchor: (id: string) => string
|
||||
setRevealMessage?: (fn: (id: string) => void) => void
|
||||
setRevealMessage?: (fn: (id: string, partID?: string) => void) => void
|
||||
setScrollToEnd?: (fn: () => void) => void
|
||||
search?: JSX.Element
|
||||
}
|
||||
|
||||
export function MessageTimeline(props: MessageTimelineProps) {
|
||||
@@ -789,6 +790,7 @@ function MessageTimelineView(
|
||||
<Show when={sessionID()} keyed>
|
||||
{(id) => (
|
||||
<div class="shrink-0 flex items-center gap-2">
|
||||
{props.search}
|
||||
<SessionContextUsage placement="bottom" />
|
||||
<Show when={!parentID() && project()}>
|
||||
{(project) => (
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
[data-component="timeline-search-bar"] [data-component="text-input-v2"] {
|
||||
background: var(--v2-background-bg-base);
|
||||
box-shadow: inset 0 0 0 1px var(--v2-border-border-base);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
[data-component="timeline-search-bar"] [data-component="text-input-v2"]:hover {
|
||||
background: var(--v2-background-bg-base);
|
||||
}
|
||||
|
||||
[data-component="timeline-search-bar"] [data-component="text-input-v2"]:focus-within {
|
||||
box-shadow: inset 0 0 0 1px var(--v2-border-border-focus);
|
||||
outline: none;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import "@opencode-ai/ui/text-input.css"
|
||||
import { Show } from "solid-js"
|
||||
import type { TimelineSearchController } from "./search-controller"
|
||||
import "./search-bar.css"
|
||||
|
||||
export function TimelineSearchBar(props: { controller: TimelineSearchController }) {
|
||||
const c = props.controller
|
||||
|
||||
return (
|
||||
<Show when={c.visible()}>
|
||||
<div data-component="timeline-search-bar" class="h-7 w-[200px] max-w-[50vw] shrink-0">
|
||||
<div data-component="text-input-v2" data-appearance="base" data-leading-icon class="!h-7 !w-full max-w-full">
|
||||
<div data-slot="text-input-v2-value">
|
||||
<span data-slot="text-input-v2-leading-icon">
|
||||
<Icon name="magnifying-glass" size="small" />
|
||||
</span>
|
||||
<input
|
||||
ref={c.element.setInput}
|
||||
data-slot="text-input-v2-input"
|
||||
type="search"
|
||||
value={c.query.value()}
|
||||
placeholder={c.query.placeholder()}
|
||||
aria-label={c.query.placeholder()}
|
||||
onInput={(event) => c.query.setValue(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
c.query.close()
|
||||
return
|
||||
}
|
||||
if (event.altKey || event.metaKey || event.ctrlKey) return
|
||||
if (event.key === "Enter" && !event.isComposing) {
|
||||
event.preventDefault()
|
||||
c.result.move(event.shiftKey ? -1 : 1)
|
||||
return
|
||||
}
|
||||
if (event.key === "ArrowDown" && !event.isComposing) {
|
||||
event.preventDefault()
|
||||
c.result.move(1)
|
||||
return
|
||||
}
|
||||
if (event.key === "ArrowUp" && !event.isComposing) {
|
||||
event.preventDefault()
|
||||
c.result.move(-1)
|
||||
return
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Show when={c.query.value()}>
|
||||
<span
|
||||
data-slot="timeline-search-count"
|
||||
class="shrink-0 self-center text-[11px] text-v2-text-text-muted [font-weight:440] tabular-nums"
|
||||
>
|
||||
{c.result.count() > 0 ? c.result.activeIndex() + 1 : 0}/{c.result.count()}
|
||||
</span>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
class="-me-1 flex size-5 shrink-0 self-center items-center justify-center rounded-[2px] border-0 bg-transparent p-0 text-v2-icon-icon-muted outline outline-1 outline-transparent hover:bg-v2-overlay-simple-overlay-hover active:bg-v2-overlay-simple-overlay-pressed focus-visible:outline-v2-border-border-focus"
|
||||
aria-label={c.query.placeholder()}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => c.query.close()}
|
||||
>
|
||||
<Icon name="xmark-small" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { Timeline } from "@opencode-ai/session-ui/timeline/projection"
|
||||
import { createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
export type TimelineSearchMatch = {
|
||||
messageID: string
|
||||
role: "user" | "assistant"
|
||||
revealID: string
|
||||
partID: string
|
||||
occurrence: number
|
||||
text: string
|
||||
}
|
||||
|
||||
const HIGHLIGHT_HIT = "timeline-search-hit"
|
||||
const HIGHLIGHT_ACTIVE = "timeline-search-hit-active"
|
||||
const TEXT_SELECTORS = '[data-slot="text-part-body"], [data-slot="user-message-text"]'
|
||||
|
||||
function supportsHighlights() {
|
||||
return typeof CSS !== "undefined" && typeof CSS.highlights === "object" && CSS.highlights !== null
|
||||
}
|
||||
|
||||
function clearHighlights() {
|
||||
if (!supportsHighlights()) return
|
||||
CSS.highlights.delete(HIGHLIGHT_HIT)
|
||||
CSS.highlights.delete(HIGHLIGHT_ACTIVE)
|
||||
}
|
||||
|
||||
function collectRanges(
|
||||
root: HTMLElement,
|
||||
query: string,
|
||||
activePartID: string | undefined,
|
||||
activeOccurrence: number | undefined,
|
||||
) {
|
||||
const hits: Range[] = []
|
||||
const active: Range[] = []
|
||||
const lower = query.toLowerCase()
|
||||
const bodies = root.querySelectorAll<HTMLElement>(TEXT_SELECTORS)
|
||||
for (const body of bodies) {
|
||||
const part = body.closest("[data-timeline-part-id]")
|
||||
const partID = part?.getAttribute("data-timeline-part-id")
|
||||
const isActivePart = activePartID !== undefined && partID === activePartID
|
||||
let occurrenceInPart = 0
|
||||
const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT)
|
||||
let node = walker.nextNode() as Text | null
|
||||
while (node) {
|
||||
const value = node.nodeValue ?? ""
|
||||
const lowerValue = value.toLowerCase()
|
||||
let from = 0
|
||||
let at = lowerValue.indexOf(lower, from)
|
||||
while (at !== -1) {
|
||||
const range = document.createRange()
|
||||
range.setStart(node, at)
|
||||
range.setEnd(node, at + query.length)
|
||||
if (isActivePart && activeOccurrence === occurrenceInPart) active.push(range)
|
||||
else hits.push(range)
|
||||
occurrenceInPart += 1
|
||||
from = at + query.length
|
||||
at = lowerValue.indexOf(lower, from)
|
||||
}
|
||||
node = walker.nextNode() as Text | null
|
||||
}
|
||||
}
|
||||
return { hits, active }
|
||||
}
|
||||
|
||||
function applyHighlights(
|
||||
root: HTMLElement,
|
||||
query: string,
|
||||
activePartID: string | undefined,
|
||||
activeOccurrence: number | undefined,
|
||||
) {
|
||||
if (!supportsHighlights()) return
|
||||
const { hits, active } = collectRanges(root, query, activePartID, activeOccurrence)
|
||||
CSS.highlights.set(HIGHLIGHT_HIT, new Highlight(...hits))
|
||||
CSS.highlights.set(HIGHLIGHT_ACTIVE, new Highlight(...active))
|
||||
}
|
||||
|
||||
export function createTimelineSearchController(input: {
|
||||
sessionID: () => string | undefined
|
||||
scrollRef: () => HTMLDivElement | undefined
|
||||
revealMessage: (id: string, partID?: string) => void
|
||||
pauseAutoScroll: () => void
|
||||
}) {
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
const data = useData()
|
||||
const [state, setState] = createStore({ value: "", active: 0, visible: false })
|
||||
const [focusTick, setFocusTick] = createSignal(0)
|
||||
let inputEl: HTMLInputElement | undefined
|
||||
|
||||
const query = createMemo(() => state.value.trim().toLowerCase())
|
||||
|
||||
const matches = createMemo<TimelineSearchMatch[]>(() => {
|
||||
const value = query()
|
||||
if (!value) return []
|
||||
const sessionID = input.sessionID()
|
||||
if (!sessionID) return []
|
||||
const messages = data.session.message.list(sessionID)
|
||||
const result: TimelineSearchMatch[] = []
|
||||
let revealID = ""
|
||||
for (const message of messages) {
|
||||
if (message.type === "user" || message.type === "shell") revealID = message.id
|
||||
if (message.type !== "user" && message.type !== "assistant") continue
|
||||
const visibleParts =
|
||||
message.type === "user"
|
||||
? [{ id: `${message.id}:text:0`, content: { type: "text" as const, text: message.text } }]
|
||||
: Timeline.contentEntries(message)
|
||||
for (const textPart of visibleParts) {
|
||||
if (textPart.content.type !== "text") continue
|
||||
const text = textPart.content.text
|
||||
if (!text) continue
|
||||
const lower = text.toLowerCase()
|
||||
let from = 0
|
||||
let occurrence = 0
|
||||
let at = lower.indexOf(value, from)
|
||||
while (at !== -1) {
|
||||
result.push({
|
||||
messageID: message.id,
|
||||
role: message.type,
|
||||
revealID,
|
||||
partID: textPart.id,
|
||||
occurrence,
|
||||
text,
|
||||
})
|
||||
occurrence += 1
|
||||
from = at + value.length
|
||||
at = lower.indexOf(value, from)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const activeIndex = createMemo(() => {
|
||||
const list = matches()
|
||||
if (list.length === 0) return 0
|
||||
if (state.active >= list.length) return 0
|
||||
if (state.active < 0) return 0
|
||||
return state.active
|
||||
})
|
||||
|
||||
const activePartID = createMemo(() => matches()[activeIndex()]?.partID)
|
||||
const activeOccurrence = createMemo(() => matches()[activeIndex()]?.occurrence)
|
||||
|
||||
createEffect(() => {
|
||||
const root = input.scrollRef()
|
||||
const q = query()
|
||||
if (!root || !state.visible || !q) {
|
||||
clearHighlights()
|
||||
return
|
||||
}
|
||||
applyHighlights(root, q, activePartID(), activeOccurrence())
|
||||
let frame: number | undefined
|
||||
const scheduleApply = () => {
|
||||
if (frame !== undefined) return
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = undefined
|
||||
if (!state.visible) return
|
||||
applyHighlights(root, query(), activePartID(), activeOccurrence())
|
||||
})
|
||||
}
|
||||
const observer = new MutationObserver(scheduleApply)
|
||||
observer.observe(root, { childList: true, subtree: true, characterData: true })
|
||||
onCleanup(() => {
|
||||
observer.disconnect()
|
||||
if (frame !== undefined) cancelAnimationFrame(frame)
|
||||
clearHighlights()
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(focusTick, () => {
|
||||
if (!state.visible) return
|
||||
requestAnimationFrame(() => {
|
||||
inputEl?.focus()
|
||||
inputEl?.select()
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
command.register("session.search", () => [
|
||||
{
|
||||
id: "session.search",
|
||||
title: language.t("session.search.placeholder"),
|
||||
keybind: "mod+f",
|
||||
hidden: true,
|
||||
onSelect: () => open(),
|
||||
},
|
||||
])
|
||||
const onOpenRequest = () => open()
|
||||
document.addEventListener("opencode:timeline-search-open", onOpenRequest)
|
||||
onCleanup(() => document.removeEventListener("opencode:timeline-search-open", onOpenRequest))
|
||||
|
||||
function open() {
|
||||
setState("visible", true)
|
||||
setFocusTick((t) => t + 1)
|
||||
}
|
||||
|
||||
function close() {
|
||||
setState({ value: "", active: 0, visible: false })
|
||||
inputEl?.blur()
|
||||
}
|
||||
|
||||
function setValue(value: string) {
|
||||
setState("value", value)
|
||||
const list = matches()
|
||||
const match = list[0]
|
||||
if (!value.trim() || !match) {
|
||||
setState("active", 0)
|
||||
return
|
||||
}
|
||||
setState("active", 0)
|
||||
input.pauseAutoScroll()
|
||||
input.revealMessage(match.revealID, match.partID)
|
||||
scrollToMatch(match)
|
||||
}
|
||||
|
||||
function scrollToMatch(match: TimelineSearchMatch) {
|
||||
let attempts = 0
|
||||
const seek = () => {
|
||||
if (!state.visible) return
|
||||
const root = input.scrollRef()
|
||||
if (!root) return
|
||||
const { active } = collectRanges(root, query(), match.partID, match.occurrence)
|
||||
if (active.length === 0) {
|
||||
if (attempts++ < 12) requestAnimationFrame(seek)
|
||||
return
|
||||
}
|
||||
const rect = active[0].getBoundingClientRect()
|
||||
const rootRect = root.getBoundingClientRect()
|
||||
const sticky = root.querySelector("[data-session-title]")
|
||||
const inset = sticky instanceof HTMLElement ? sticky.offsetHeight : 0
|
||||
const top = rect.top - rootRect.top + root.scrollTop - inset - (rootRect.height - rect.height) / 2
|
||||
root.scrollTo({ top: Math.max(0, top), behavior: "auto" })
|
||||
}
|
||||
requestAnimationFrame(seek)
|
||||
}
|
||||
|
||||
function move(delta: number) {
|
||||
const list = matches()
|
||||
if (list.length === 0) return
|
||||
const next = (activeIndex() + delta + list.length) % list.length
|
||||
setState("active", next)
|
||||
const match = list[next]
|
||||
if (!match) return
|
||||
input.pauseAutoScroll()
|
||||
input.revealMessage(match.revealID, match.partID)
|
||||
scrollToMatch(match)
|
||||
}
|
||||
|
||||
return {
|
||||
visible: () => state.visible,
|
||||
query: {
|
||||
value: () => state.value,
|
||||
placeholder: () => language.t("session.search.placeholder"),
|
||||
noResults: () => language.t("session.search.noResults"),
|
||||
open,
|
||||
close,
|
||||
setValue,
|
||||
},
|
||||
result: {
|
||||
activeIndex,
|
||||
count: () => matches().length,
|
||||
move,
|
||||
},
|
||||
element: {
|
||||
setInput: (element: HTMLInputElement) => (inputEl = element),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type TimelineSearchController = ReturnType<typeof createTimelineSearchController>
|
||||
@@ -69,7 +69,7 @@ type Input = {
|
||||
row: TimelineRow.TimelineRow,
|
||||
disclosure: Readonly<Record<string, boolean | undefined>>,
|
||||
) => boolean
|
||||
setRevealMessage?: (fn: (id: string) => void) => void
|
||||
setRevealMessage?: (fn: (id: string, partID?: string) => void) => void
|
||||
setScrollToEnd?: (fn: () => void) => void
|
||||
}
|
||||
|
||||
@@ -271,8 +271,13 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => String(item.key)))
|
||||
|
||||
createEffect(() => {
|
||||
input.setRevealMessage?.((id) => {
|
||||
const index = input.projection.messageRowIndex().get(id)
|
||||
input.setRevealMessage?.((id, partID) => {
|
||||
const partIndex = partID
|
||||
? rows().findIndex(
|
||||
(row) => row._tag === "AssistantPart" && row.group.type === "part" && row.group.ref.partID === partID,
|
||||
)
|
||||
: -1
|
||||
const index = partIndex >= 0 ? partIndex : input.projection.messageRowIndex().get(id)
|
||||
if (index === undefined) return
|
||||
virtualizer.scrollToIndex(index, { align: "center" })
|
||||
})
|
||||
|
||||
@@ -605,7 +605,10 @@ export type SessionLogOutput =
|
||||
readonly type: "session.execution.interrupted"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: { readonly sessionID: Session.ID; readonly reason: "user" | "shutdown" | "superseded" }
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly reason: "user" | "shutdown" | "superseded" | "inactivity"
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
@@ -1426,6 +1429,7 @@ export type ProjectListOperation<E = never> = () => Effect.Effect<ProjectListOut
|
||||
|
||||
export type ProjectUpdateInput = {
|
||||
readonly projectID: Project.ID
|
||||
readonly canonical?: AbsolutePath | undefined
|
||||
readonly name?: string | undefined
|
||||
readonly icon?: Project.Icon | undefined
|
||||
readonly commands?: Project.Commands | undefined
|
||||
|
||||
@@ -1018,7 +1018,7 @@ const EndpointProjectUpdate = (raw: RawClient["server.project"]) => (input: Proj
|
||||
preserveEffect<ProjectUpdateOutput>()(
|
||||
raw["project.update"]({
|
||||
params: { projectID: input["projectID"] },
|
||||
payload: { name: input["name"], icon: input["icon"], commands: input["commands"] },
|
||||
payload: { canonical: input["canonical"], name: input["name"], icon: input["icon"], commands: input["commands"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
|
||||
@@ -1377,7 +1377,12 @@ export function make(options: ClientOptions) {
|
||||
{
|
||||
method: "PATCH",
|
||||
path: `/api/project/${encodeURIComponent(input.projectID)}`,
|
||||
body: { name: input["name"], icon: input["icon"], commands: input["commands"] },
|
||||
body: {
|
||||
canonical: input["canonical"],
|
||||
name: input["name"],
|
||||
icon: input["icon"],
|
||||
commands: input["commands"],
|
||||
},
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401, 404],
|
||||
empty: false,
|
||||
|
||||
@@ -710,7 +710,7 @@ export type SessionExecutionInterrupted = {
|
||||
type: "session.execution.interrupted"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; reason: "user" | "shutdown" | "superseded" }
|
||||
data: { sessionID: string; reason: "user" | "shutdown" | "superseded" | "inactivity" }
|
||||
}
|
||||
|
||||
export type SessionInstructionsUpdated = {
|
||||
@@ -4672,17 +4672,26 @@ export type ProjectListOutput = Array<Project>
|
||||
|
||||
export type ProjectUpdateInput = {
|
||||
readonly projectID: { readonly projectID: string }["projectID"]
|
||||
readonly canonical?: {
|
||||
readonly canonical?: string
|
||||
readonly name?: string
|
||||
readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string }
|
||||
readonly commands?: { readonly start?: string }
|
||||
}["canonical"]
|
||||
readonly name?: {
|
||||
readonly canonical?: string
|
||||
readonly name?: string
|
||||
readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string }
|
||||
readonly commands?: { readonly start?: string }
|
||||
}["name"]
|
||||
readonly icon?: {
|
||||
readonly canonical?: string
|
||||
readonly name?: string
|
||||
readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string }
|
||||
readonly commands?: { readonly start?: string }
|
||||
}["icon"]
|
||||
readonly commands?: {
|
||||
readonly canonical?: string
|
||||
readonly name?: string
|
||||
readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string }
|
||||
readonly commands?: { readonly start?: string }
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
export * as Credential from "./credential.js"
|
||||
|
||||
import { asc, desc, eq } from "drizzle-orm"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Cause, Context, Effect, Layer, Schema } from "effect"
|
||||
import { Credential } from "@opencode-ai/schema/credential"
|
||||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
import { Database } from "./database/database.js"
|
||||
import { Bus } from "./bus.js"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { CredentialTable } from "./credential/sql.js"
|
||||
import { ErrorSummary } from "./util/error-summary.js"
|
||||
|
||||
export const ID = Credential.ID
|
||||
export type ID = Credential.ID
|
||||
@@ -123,7 +124,21 @@ const layer = Layer.effect(
|
||||
.run()
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
.pipe(
|
||||
Effect.onError((cause) =>
|
||||
Effect.logError("credential create failed", {
|
||||
credentialID: credential.id,
|
||||
integrationID: credential.integrationID,
|
||||
errors: ErrorSummary.from(Cause.squash(cause)),
|
||||
}),
|
||||
),
|
||||
Effect.orDie,
|
||||
)
|
||||
yield* Effect.logInfo("credential created", {
|
||||
credentialID: credential.id,
|
||||
integrationID: credential.integrationID,
|
||||
type: credential.value.type,
|
||||
})
|
||||
yield* bus.publish(Event.Updated, {}, { global: true })
|
||||
yield* bus.publish(
|
||||
Event.Switched,
|
||||
@@ -154,8 +169,19 @@ const layer = Layer.effect(
|
||||
return credential.integration_id
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
if (integrationID) yield* bus.publish(Event.Switched, { integrationID, credentialID: id }, { global: true })
|
||||
.pipe(
|
||||
Effect.onError((cause) =>
|
||||
Effect.logError("credential activate failed", {
|
||||
credentialID: id,
|
||||
errors: ErrorSummary.from(Cause.squash(cause)),
|
||||
}),
|
||||
),
|
||||
Effect.orDie,
|
||||
)
|
||||
if (integrationID) {
|
||||
yield* Effect.logInfo("credential activated", { integrationID, credentialID: id })
|
||||
yield* bus.publish(Event.Switched, { integrationID, credentialID: id }, { global: true })
|
||||
}
|
||||
}),
|
||||
update: Effect.fn("Credential.update")(function* (id, updates) {
|
||||
if (updates.label === undefined && updates.value === undefined) return
|
||||
@@ -164,15 +190,46 @@ const layer = Layer.effect(
|
||||
.from(CredentialTable)
|
||||
.where(eq(CredentialTable.id, id))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!credential?.integrationID) return
|
||||
.pipe(
|
||||
Effect.onError((cause) =>
|
||||
Effect.logError("credential update lookup failed", {
|
||||
credentialID: id,
|
||||
errors: ErrorSummary.from(Cause.squash(cause)),
|
||||
}),
|
||||
),
|
||||
Effect.orDie,
|
||||
)
|
||||
if (!credential?.integrationID) {
|
||||
yield* Effect.logWarning("credential update skipped", { credentialID: id, reason: "credential_missing" })
|
||||
return
|
||||
}
|
||||
if (updates.label === credential.label && updates.value === undefined) return
|
||||
yield* db
|
||||
const updated = yield* db
|
||||
.update(CredentialTable)
|
||||
.set({ label: updates.label, value: updates.value })
|
||||
.where(eq(CredentialTable.id, id))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
.returning({ id: CredentialTable.id })
|
||||
.get()
|
||||
.pipe(
|
||||
Effect.onError((cause) =>
|
||||
Effect.logError("credential update failed", {
|
||||
credentialID: id,
|
||||
integrationID: credential.integrationID,
|
||||
errors: ErrorSummary.from(Cause.squash(cause)),
|
||||
}),
|
||||
),
|
||||
Effect.orDie,
|
||||
)
|
||||
if (!updated) {
|
||||
yield* Effect.logWarning("credential update skipped", { credentialID: id, reason: "credential_removed" })
|
||||
return
|
||||
}
|
||||
yield* Effect.logInfo("credential updated", {
|
||||
credentialID: id,
|
||||
integrationID: credential.integrationID,
|
||||
valueChanged: updates.value !== undefined,
|
||||
labelChanged: updates.label !== undefined && updates.label !== credential.label,
|
||||
})
|
||||
if (updates.label !== undefined && updates.label !== credential.label)
|
||||
yield* bus.publish(Event.Updated, {}, { global: true })
|
||||
}),
|
||||
@@ -191,7 +248,8 @@ const layer = Layer.effect(
|
||||
.get()
|
||||
: undefined
|
||||
yield* tx.delete(CredentialTable).where(eq(CredentialTable.id, id)).run()
|
||||
if (!credential.integration_id || active?.id !== id) return { switched: false as const }
|
||||
if (!credential.integration_id || active?.id !== id)
|
||||
return { switched: false as const, integrationID: credential.integration_id }
|
||||
const replacement = yield* tx
|
||||
.select({ id: CredentialTable.id })
|
||||
.from(CredentialTable)
|
||||
@@ -217,8 +275,22 @@ const layer = Layer.effect(
|
||||
}
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
.pipe(
|
||||
Effect.onError((cause) =>
|
||||
Effect.logError("credential remove failed", {
|
||||
credentialID: id,
|
||||
errors: ErrorSummary.from(Cause.squash(cause)),
|
||||
}),
|
||||
),
|
||||
Effect.orDie,
|
||||
)
|
||||
if (!removed) return
|
||||
yield* Effect.logInfo("credential removed", {
|
||||
credentialID: id,
|
||||
integrationID: removed.integrationID,
|
||||
active: removed.switched,
|
||||
...(removed.switched ? { replacementID: removed.credentialID } : {}),
|
||||
})
|
||||
yield* bus.publish(Event.Updated, {}, { global: true })
|
||||
if (removed.switched)
|
||||
yield* bus.publish(
|
||||
|
||||
@@ -5,6 +5,8 @@ import { Bus } from "./bus.js"
|
||||
import { Location } from "./location.js"
|
||||
import { LocationServiceMap } from "./location-service-map.js"
|
||||
import { SessionEvent } from "./session/event.js"
|
||||
import { SessionExecution } from "./session/execution.js"
|
||||
import { SessionStore } from "./session/store.js"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
|
||||
const isSessionEvent = Schema.is(SessionEvent.Durable)
|
||||
@@ -18,9 +20,11 @@ export function layer(options: { readonly timeToLive?: Duration.Input; readonly
|
||||
const clock = yield* Clock.Clock
|
||||
const bus = yield* Bus.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const sessions = yield* SessionStore.Service
|
||||
const timeToLive = Duration.toMillis(options.timeToLive ?? "60 minutes")
|
||||
const entries = new Map<string, { readonly ref: Location.Ref; expiresAt: number }>()
|
||||
const key = (ref: Location.Ref) => `${ref.directory}\0${ref.workspaceID ?? ""}`
|
||||
const key = (ref: Location.Ref) => `${LocationServiceMap.canonical(ref).directory}\0${ref.workspaceID ?? ""}`
|
||||
const touch = (ref: Location.Ref) =>
|
||||
Effect.sync(() => {
|
||||
entries.set(key(ref), { ref, expiresAt: clock.currentTimeMillisUnsafe() + timeToLive })
|
||||
@@ -39,26 +43,44 @@ export function layer(options: { readonly timeToLive?: Duration.Input; readonly
|
||||
yield* Effect.sleep(options.sweepInterval ?? "1 minute")
|
||||
const refs = Array.from(yield* RcMap.keys(locations.rcMap))
|
||||
const cached = new Set(refs.map(key))
|
||||
yield* Effect.forEach(
|
||||
refs,
|
||||
(ref) => (entries.has(key(ref)) ? Effect.void : touch(ref)),
|
||||
{ discard: true },
|
||||
)
|
||||
yield* Effect.forEach(refs, (ref) => (entries.has(key(ref)) ? Effect.void : touch(ref)), { discard: true })
|
||||
for (const id of entries.keys()) {
|
||||
if (!cached.has(id)) entries.delete(id)
|
||||
}
|
||||
const now = clock.currentTimeMillisUnsafe()
|
||||
const expired = Array.from(entries.values()).filter((entry) => entry.expiresAt <= now)
|
||||
if (expired.length === 0) return
|
||||
const active = yield* Effect.forEach(yield* execution.active, (sessionID) => sessions.get(sessionID))
|
||||
yield* Effect.forEach(
|
||||
expired,
|
||||
(entry) => {
|
||||
entries.delete(key(entry.ref))
|
||||
return Effect.logInfo("location services evicted", {
|
||||
directory: entry.ref.directory,
|
||||
workspaceID: entry.ref.workspaceID,
|
||||
}).pipe(Effect.andThen(locations.invalidate(entry.ref)))
|
||||
},
|
||||
{ discard: true },
|
||||
(entry) =>
|
||||
Effect.gen(function* () {
|
||||
const owners = active.flatMap((session) =>
|
||||
session && key(session.location) === key(entry.ref) ? [session] : [],
|
||||
)
|
||||
// Invalidation only detaches the cache entry; borrowers retain the old
|
||||
// graph. Stop its executions and settle tool cleanup before detaching it.
|
||||
yield* Effect.forEach(
|
||||
owners,
|
||||
(session) => execution.interrupt(session.id, { reason: "inactivity", awaitSettlement: true }),
|
||||
{
|
||||
discard: true,
|
||||
concurrency: "unbounded",
|
||||
},
|
||||
)
|
||||
const remaining = yield* Effect.forEach(yield* execution.active, (sessionID) => sessions.get(sessionID))
|
||||
// New work admitted during cleanup may now own the cached graph.
|
||||
if (remaining.some((session) => session && key(session.location) === key(entry.ref))) {
|
||||
yield* touch(entry.ref)
|
||||
return
|
||||
}
|
||||
entries.delete(key(entry.ref))
|
||||
yield* Effect.logInfo("location services evicted", {
|
||||
directory: entry.ref.directory,
|
||||
workspaceID: entry.ref.workspaceID,
|
||||
}).pipe(Effect.andThen(locations.invalidate(entry.ref)))
|
||||
}),
|
||||
{ discard: true, concurrency: "unbounded" },
|
||||
)
|
||||
}).pipe(Effect.forever, Effect.forkScoped)
|
||||
|
||||
@@ -70,5 +92,5 @@ export function layer(options: { readonly timeToLive?: Duration.Input; readonly
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer(),
|
||||
deps: [Bus.node, LocationServiceMap.node],
|
||||
deps: [Bus.node, LocationServiceMap.node, SessionExecution.node, SessionStore.node],
|
||||
})
|
||||
|
||||
@@ -231,6 +231,8 @@ export const connect = Effect.fnUntraced(function* (
|
||||
}
|
||||
if (!URL.canParse(config.url))
|
||||
return yield* new ConnectError({ server, message: `Invalid MCP URL for "${server}"` })
|
||||
const { McpOAuth } = yield* Effect.promise(() => import("./oauth.js"))
|
||||
const fetch = yield* McpOAuth.loggedFetch({ server, directory })
|
||||
// Prefer raw tools for our Code Mode without changing the configured URL used for OAuth identity.
|
||||
const url = new URL(config.url)
|
||||
const addedCodemode = config.codemode !== false && !url.searchParams.has("codemode")
|
||||
@@ -240,6 +242,7 @@ export const connect = Effect.fnUntraced(function* (
|
||||
new StreamableHTTPClientTransport(url, {
|
||||
requestInit: config.headers ? { headers: config.headers } : undefined,
|
||||
authProvider,
|
||||
fetch,
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -259,27 +259,41 @@ export const layer = (options?: Options) =>
|
||||
const { McpOAuth } = yield* Effect.promise(() => import("./oauth.js"))
|
||||
const remote = entry.config
|
||||
const oauth = remote.oauth || undefined
|
||||
const run = Effect.runPromiseWith(yield* Effect.context())
|
||||
const base = {
|
||||
redirectUrl: oauth?.redirect_uri ?? "http://127.0.0.1/callback",
|
||||
scope: oauth?.scope,
|
||||
client: oauth?.client_id ? { id: oauth.client_id, secret: oauth.client_secret } : undefined,
|
||||
// No browser during connect: an auth-gated server surfaces needs_auth instead of opening a browser.
|
||||
onRedirect: () => {},
|
||||
onRedirect: () => run(Effect.logInfo("mcp oauth authorization required")),
|
||||
}
|
||||
const found = (yield* credentials.list(entry.integrationID)).at(-1)
|
||||
if (!found || found.value.type !== "oauth")
|
||||
if (!found || found.value.type !== "oauth") {
|
||||
// No stored credential yet: an empty in-memory store still lets the SDK run the auth handshake, which
|
||||
// ends in UnauthorizedError -> needs_auth. Returning no provider instead would let the transport throw
|
||||
// a raw HTTP error, hiding the auth requirement behind a generic failed status. Anonymous servers are
|
||||
// unaffected: tokens() returns undefined, so no auth header is sent and the SDK never calls auth().
|
||||
yield* Effect.logInfo("mcp oauth credential unavailable", {
|
||||
integrationID: entry.integrationID,
|
||||
reason: found ? "not_oauth" : "missing",
|
||||
})
|
||||
return McpOAuth.provider({ ...base, store: McpOAuth.memoryStore() })
|
||||
}
|
||||
const credentialID = found.id
|
||||
const methodID = found.value.methodID
|
||||
const fields = { credentialID, integrationID: entry.integrationID }
|
||||
yield* Effect.logInfo("mcp oauth credential loaded", {
|
||||
...fields,
|
||||
hasRefreshToken: Boolean(found.value.refresh),
|
||||
hasClientInformation: Boolean(McpOAuth.clientFromCredential(found.value)),
|
||||
expiresAt: found.value.expires,
|
||||
expired: found.value.expires !== 0 && found.value.expires <= Date.now(),
|
||||
})
|
||||
// Tracks the refresh token this provider last presented, so invalidate can tell whether the SDK
|
||||
// rejected the currently-stored credential or a snapshot another connection has already rotated past.
|
||||
let presented = found.value.refresh
|
||||
const readOAuthCredential = async () => {
|
||||
const stored = await Effect.runPromise(credentials.get(credentialID))
|
||||
const stored = await run(credentials.get(credentialID))
|
||||
return stored?.value.type === "oauth" ? stored.value : undefined
|
||||
}
|
||||
return McpOAuth.provider({
|
||||
@@ -290,10 +304,25 @@ export const layer = (options?: Options) =>
|
||||
// strand every connection in needs_auth until a manual re-auth. Credential deletion notifies all locations;
|
||||
// reconnects remain serialized by the server lock.
|
||||
invalidate: async (scope) => {
|
||||
if (scope === "verifier" || scope === "discovery") return
|
||||
if (scope === "verifier" || scope === "discovery") {
|
||||
await run(
|
||||
Effect.logDebug("mcp oauth invalidation skipped", { ...fields, scope, reason: "not_credentials" }),
|
||||
)
|
||||
return
|
||||
}
|
||||
const oauth = await readOAuthCredential()
|
||||
if (!oauth || oauth.refresh !== presented) return
|
||||
await Effect.runPromise(credentials.remove(credentialID))
|
||||
if (!oauth || oauth.refresh !== presented) {
|
||||
await run(
|
||||
Effect.logInfo("mcp oauth invalidation skipped", {
|
||||
...fields,
|
||||
scope,
|
||||
reason: oauth ? "token_rotated" : "credential_missing",
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
await run(Effect.logWarning("mcp oauth credential invalidation requested", { ...fields, scope }))
|
||||
await run(credentials.remove(credentialID))
|
||||
},
|
||||
// Always read the latest stored tokens instead of caching at connect time: with refresh-token rotation,
|
||||
// a cached snapshot goes stale the moment another connection refreshes, and re-presenting the consumed
|
||||
@@ -314,7 +343,16 @@ export const layer = (options?: Options) =>
|
||||
client: previous ? McpOAuth.clientFromCredential(previous) : undefined,
|
||||
})
|
||||
presented = value.refresh
|
||||
await Effect.runPromise(credentials.update(credentialID, { value }))
|
||||
await run(
|
||||
Effect.logInfo("mcp oauth tokens received", {
|
||||
...fields,
|
||||
credentialPresent: Boolean(previous),
|
||||
refreshRotated: Boolean(previous && previous.refresh !== value.refresh),
|
||||
hasRefreshToken: Boolean(value.refresh),
|
||||
expiresAt: value.expires,
|
||||
}),
|
||||
)
|
||||
await run(credentials.update(credentialID, { value }))
|
||||
},
|
||||
clientInformation: async () => {
|
||||
const oauth = await readOAuthCredential()
|
||||
@@ -560,7 +598,10 @@ export const layer = (options?: Options) =>
|
||||
: { status: "failed", error: error instanceof Error ? error.message : String(error) }
|
||||
yield* Effect.logWarning("mcp connect failed", { server: name, status: entry.status })
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name })
|
||||
}).pipe(Effect.ensuring(entry.startup.open))
|
||||
}).pipe(
|
||||
Effect.ensuring(entry.startup.open),
|
||||
Effect.annotateLogs({ server: name, directory: location.directory, connectionID: crypto.randomUUID() }),
|
||||
)
|
||||
|
||||
const stopServer = Effect.fnUntraced(function* (name: ServerName, entry: ServerEntry) {
|
||||
const scope = entry.scope
|
||||
|
||||
@@ -1,12 +1,63 @@
|
||||
export * as McpOAuth from "./oauth.js"
|
||||
|
||||
import { auth, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import { auth, parseErrorResponse, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import type { OAuthClientInformationMixed, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"
|
||||
import { Deferred, Effect } from "effect"
|
||||
import type { FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js"
|
||||
import { Cause, Deferred, Effect } from "effect"
|
||||
import { Credential } from "@opencode-ai/schema/credential"
|
||||
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
|
||||
import { OauthCallbackPage } from "../oauth/page.js"
|
||||
import type { Integration } from "../integration.js"
|
||||
import { ErrorSummary } from "../util/error-summary.js"
|
||||
|
||||
/** Observe OAuth failures before the SDK handles them by invalidating credentials or redirecting. */
|
||||
export const loggedFetch = (fields: { readonly server: string; readonly directory?: string }) =>
|
||||
Effect.gen(function* () {
|
||||
const run = Effect.runPromiseWith(yield* Effect.context())
|
||||
const request: FetchLike = (url, init) => {
|
||||
const grant = init?.body instanceof URLSearchParams ? init.body.get("grant_type") : undefined
|
||||
const operation = grant === "refresh_token" ? "refresh" : grant === "authorization_code" ? "exchange" : undefined
|
||||
const started = Date.now()
|
||||
return run(
|
||||
Effect.gen(function* () {
|
||||
if (operation) yield* Effect.logInfo("mcp oauth request started")
|
||||
const response = yield* Effect.tryPromise({ try: () => fetch(url, init), catch: (error) => error })
|
||||
const result = { status: response.status, durationMs: Date.now() - started }
|
||||
if (operation && !response.ok) {
|
||||
// Only retain the SDK's standard error code. Descriptions and raw bodies can echo credentials.
|
||||
const error = yield* Effect.tryPromise(async () => parseErrorResponse(await response.clone().text())).pipe(
|
||||
Effect.map((error) => error.errorCode),
|
||||
Effect.orElseSucceed(() => "unreadable_response"),
|
||||
)
|
||||
yield* Effect.logWarning("mcp oauth request rejected", { ...result, error })
|
||||
}
|
||||
if (operation && response.ok) {
|
||||
yield* Effect.logInfo("mcp oauth request succeeded", result)
|
||||
}
|
||||
if (!operation && (response.status === 401 || response.status === 403)) {
|
||||
yield* Effect.logWarning("mcp http authentication rejected", result)
|
||||
}
|
||||
return response
|
||||
}).pipe(
|
||||
Effect.onError((cause) => {
|
||||
if (init?.signal?.aborted) return Effect.logDebug("mcp http request aborted")
|
||||
return Effect.logWarning("mcp http request failed", {
|
||||
errors: ErrorSummary.from(Cause.squash(cause)),
|
||||
durationMs: Date.now() - started,
|
||||
})
|
||||
}),
|
||||
Effect.annotateLogs({
|
||||
...fields,
|
||||
requestID: crypto.randomUUID(),
|
||||
origin: new URL(url).origin,
|
||||
method: init?.method ?? "GET",
|
||||
...(operation ? { operation } : {}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
return request
|
||||
})
|
||||
|
||||
/** Persists the OAuth artifacts for one MCP server session: DCR client info, PKCE verifier, and tokens. */
|
||||
export interface Store {
|
||||
@@ -145,6 +196,12 @@ export const authorize = (input: {
|
||||
readonly methodID: Integration.MethodID
|
||||
}) =>
|
||||
Effect.gen(function* () {
|
||||
const fields = { server: input.name, methodID: input.methodID, oauthAttemptID: crypto.randomUUID() }
|
||||
const context = yield* Effect.context()
|
||||
const run = Effect.runPromiseWith(context)
|
||||
const runFork = Effect.runForkWith(context)
|
||||
const fetchFn = yield* loggedFetch({ server: input.name }).pipe(Effect.annotateLogs(fields))
|
||||
yield* Effect.logInfo("mcp oauth authorization started", fields)
|
||||
const oauth = input.config.oauth || undefined
|
||||
const store = memoryStore()
|
||||
const code = yield* Deferred.make<string, Error>()
|
||||
@@ -160,19 +217,20 @@ export const authorize = (input: {
|
||||
response.writeHead(404).end("Not found")
|
||||
return
|
||||
}
|
||||
const fail = (reason: string) => {
|
||||
const fail = (reason: string, failure: string) => {
|
||||
runFork(Effect.logWarning("mcp oauth callback rejected", { ...fields, reason: failure }))
|
||||
Effect.runFork(Deferred.fail(code, new Error(reason)))
|
||||
response
|
||||
.writeHead(400, { "Content-Type": "text/html" })
|
||||
.end(OauthCallbackPage.error(reason, { provider: input.name }))
|
||||
}
|
||||
const error = url.searchParams.get("error_description") ?? url.searchParams.get("error")
|
||||
if (error) return fail(error)
|
||||
if (error) return fail(error, "authorization_error")
|
||||
// Reject a redirect whose state does not match what we issued: this is the CSRF defense the
|
||||
// state parameter exists for, so an attacker can't inject their own authorization code.
|
||||
if (url.searchParams.get("state") !== state) return fail("OAuth state mismatch")
|
||||
if (url.searchParams.get("state") !== state) return fail("OAuth state mismatch", "state_mismatch")
|
||||
const value = url.searchParams.get("code")
|
||||
if (!value) return fail("Missing authorization code")
|
||||
if (!value) return fail("Missing authorization code", "missing_code")
|
||||
Effect.runFork(Deferred.succeed(code, value))
|
||||
response.writeHead(200, { "Content-Type": "text/html" }).end(OauthCallbackPage.success({ provider: input.name }))
|
||||
})
|
||||
@@ -202,6 +260,7 @@ export const authorize = (input: {
|
||||
client: oauth?.client_id ? { id: oauth.client_id, secret: oauth.client_secret } : undefined,
|
||||
onRedirect: (url) => {
|
||||
authorizationUrl = url
|
||||
return run(Effect.logInfo("mcp oauth awaiting authorization", fields))
|
||||
},
|
||||
store,
|
||||
})
|
||||
@@ -210,11 +269,16 @@ export const authorize = (input: {
|
||||
const tokens = yield* Effect.promise(() => store.tokens())
|
||||
if (!tokens) return yield* Effect.fail(new Error(`MCP server "${input.name}" did not return OAuth tokens`))
|
||||
const client = yield* Effect.promise(() => store.clientInformation())
|
||||
yield* Effect.logInfo("mcp oauth authorization completed", {
|
||||
...fields,
|
||||
hasRefreshToken: Boolean(tokens.refresh_token),
|
||||
expiresIn: tokens.expires_in,
|
||||
})
|
||||
return toCredential({ methodID: input.methodID, serverUrl: input.config.url, tokens, client })
|
||||
})
|
||||
|
||||
yield* Effect.tryPromise({
|
||||
try: () => auth(oauthProvider, { serverUrl: input.config.url, scope: oauth?.scope }),
|
||||
try: () => auth(oauthProvider, { serverUrl: input.config.url, scope: oauth?.scope, fetchFn }),
|
||||
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
||||
})
|
||||
|
||||
@@ -229,11 +293,28 @@ export const authorize = (input: {
|
||||
Effect.flatMap((value) =>
|
||||
Effect.tryPromise({
|
||||
try: () =>
|
||||
auth(oauthProvider, { serverUrl: input.config.url, authorizationCode: value, scope: oauth?.scope }),
|
||||
auth(oauthProvider, {
|
||||
serverUrl: input.config.url,
|
||||
authorizationCode: value,
|
||||
scope: oauth?.scope,
|
||||
fetchFn,
|
||||
}),
|
||||
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
||||
}),
|
||||
),
|
||||
Effect.flatMap(() => finalize),
|
||||
Effect.onError((cause) =>
|
||||
Effect.logWarning("mcp oauth authorization failed", { errors: ErrorSummary.from(Cause.squash(cause)) }),
|
||||
),
|
||||
Effect.annotateLogs(fields),
|
||||
),
|
||||
}
|
||||
})
|
||||
}).pipe(
|
||||
Effect.onError((cause) =>
|
||||
Effect.logWarning("mcp oauth authorization setup failed", {
|
||||
server: input.name,
|
||||
methodID: input.methodID,
|
||||
errors: ErrorSummary.from(Cause.squash(cause)),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -52,7 +52,7 @@ export const call = <F extends Schema.Struct.Fields, R extends Schema.Struct.Fie
|
||||
}),
|
||||
)
|
||||
return yield* Effect.gen(function* () {
|
||||
const response = yield* HttpClient.filterStatusOk(http).execute(request)
|
||||
const response = yield* HttpClient.withScope(HttpClient.filterStatusOk(http)).execute(request)
|
||||
const body = yield* collectBoundedResponseBody(
|
||||
response,
|
||||
MAX_RESPONSE_BYTES,
|
||||
@@ -60,6 +60,7 @@ export const call = <F extends Schema.Struct.Fields, R extends Schema.Struct.Fie
|
||||
)
|
||||
return yield* parseResponse(body.toString("utf8"), schema.output)
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.timeoutOrElse({
|
||||
duration: Duration.seconds(25),
|
||||
orElse: () => Effect.fail(new Error(`${tool} request timed out`)),
|
||||
|
||||
@@ -63,10 +63,11 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
|
||||
max_results: 8,
|
||||
}),
|
||||
)
|
||||
const response = yield* HttpClient.filterStatusOk(http)
|
||||
const response = yield* HttpClient.withScope(HttpClient.filterStatusOk(http))
|
||||
.execute(request)
|
||||
.pipe(
|
||||
Effect.flatMap(HttpClientResponse.schemaBodyJson(SearchResponse)),
|
||||
Effect.scoped,
|
||||
Effect.timeoutOrElse({
|
||||
duration: Duration.seconds(25),
|
||||
orElse: () => Effect.fail(new Error("Tavily web search request timed out")),
|
||||
|
||||
@@ -212,6 +212,7 @@ const layer = Layer.effect(
|
||||
const row = yield* db
|
||||
.update(ProjectTable)
|
||||
.set({
|
||||
worktree: input.canonical,
|
||||
name: input.name === undefined ? undefined : input.name || null,
|
||||
icon_url_override: input.icon?.override === undefined ? undefined : input.icon.override || null,
|
||||
icon_color: input.icon?.color === undefined ? undefined : input.icon.color || null,
|
||||
|
||||
@@ -128,7 +128,7 @@ const layer = Layer.effect(
|
||||
if (!agent.info) return yield* new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id })
|
||||
const loaded = yield* Effect.all(
|
||||
{
|
||||
tools: registry.snapshot(agent.info.permissions),
|
||||
tools: registry.snapshot(agent.info.permissions, { sessionID, agent: agent.id }),
|
||||
builtins: builtins.load(sessionID),
|
||||
discovery: discovery.load(),
|
||||
skills: skillInstructions.load(agent),
|
||||
|
||||
@@ -28,9 +28,17 @@ export interface Interface {
|
||||
* Interrupt active work owned by this process. Idle interruption is a no-op. Resolves once
|
||||
* the interruption is accepted; cleanup settles asynchronously in the execution fiber.
|
||||
* Returns whether an active execution was interrupted. Compose with `awaitIdle` when
|
||||
* settlement matters.
|
||||
* settlement matters. `awaitSettlement` waits only for the interrupted execution,
|
||||
* rather than fresh work admitted during its cleanup.
|
||||
*/
|
||||
readonly interrupt: (sessionID: SessionSchema.ID, options?: { readonly continue?: boolean }) => Effect.Effect<boolean>
|
||||
readonly interrupt: (
|
||||
sessionID: SessionSchema.ID,
|
||||
options?: {
|
||||
readonly continue?: boolean
|
||||
readonly reason?: "user" | "inactivity"
|
||||
readonly awaitSettlement?: boolean
|
||||
},
|
||||
) => Effect.Effect<boolean>
|
||||
/** Resolves once this process owns no active execution for the Session. Returns immediately when idle and never starts work. */
|
||||
readonly awaitIdle: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
}
|
||||
@@ -38,7 +46,7 @@ export interface Interface {
|
||||
/** Routes execution from a Session ID to its selected instance's runner. */
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionExecution") {}
|
||||
|
||||
type InterruptReason = "user" | "shutdown"
|
||||
type InterruptReason = "user" | "shutdown" | "inactivity"
|
||||
|
||||
export function terminal(exit: Exit.Exit<void, SessionRunner.RunError>, reason?: InterruptReason) {
|
||||
if (Exit.isSuccess(exit)) return { type: "succeeded" as const }
|
||||
@@ -120,9 +128,8 @@ export const layer = Layer.effect(
|
||||
return
|
||||
}
|
||||
if (outcome.type === "interrupted") {
|
||||
// A user cancel releases the claim: the turn must not resurrect at the next
|
||||
// boot. Shutdown interruption keeps it for restart continuity.
|
||||
if (outcome.reason === "user") yield* jobs.cancel(sessionID)
|
||||
// Deliberate stops release the claim; shutdown keeps it for restart continuity.
|
||||
if (outcome.reason !== "shutdown") yield* jobs.cancel(sessionID)
|
||||
yield* bus.publish(
|
||||
SessionEvent.Execution.Interrupted,
|
||||
{ sessionID, reason: outcome.reason },
|
||||
@@ -147,7 +154,7 @@ export const layer = Layer.effect(
|
||||
isActive: coordinator.isActive,
|
||||
interrupt: (sessionID, options) =>
|
||||
Effect.gen(function* () {
|
||||
const interrupted = yield* coordinator.interrupt(sessionID, "user")
|
||||
const interrupted = yield* coordinator.interrupt(sessionID, options?.reason ?? "user", options)
|
||||
if (!options?.continue) return interrupted
|
||||
// Resume steering input and between-turn control work from the interrupted
|
||||
// intent. Queued next-turn prompts stay parked: a steer-scoped drain never
|
||||
|
||||
@@ -17,9 +17,14 @@ export interface Coordinator<Key, E, Reason = never> {
|
||||
* Stops the active execution and clears its doorbell. No-op when idle. Resolves once the
|
||||
* interruption is accepted, not when cleanup settles: the execution fiber finishes its
|
||||
* finalizers and settled hook on its own time. Returns whether an active execution was
|
||||
* interrupted. Compose with `awaitIdle` for settlement.
|
||||
* interrupted. `awaitSettlement` waits for this execution's cleanup and settled hook,
|
||||
* without following fresh work admitted during cleanup. `awaitIdle` follows successors too.
|
||||
*/
|
||||
readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect<boolean>
|
||||
readonly interrupt: (
|
||||
key: Key,
|
||||
reason?: Reason,
|
||||
options?: { readonly awaitSettlement?: boolean },
|
||||
) => Effect.Effect<boolean>
|
||||
/** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */
|
||||
readonly awaitIdle: (key: Key) => Effect.Effect<void>
|
||||
}
|
||||
@@ -170,5 +175,22 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
return Deferred.await(execution.done).pipe(Effect.ignoreCause, Effect.andThen(awaitIdle(key)))
|
||||
})
|
||||
|
||||
return { active: Effect.sync(() => new Set(executions.keys())), isActive, run, wake, interrupt, awaitIdle }
|
||||
return {
|
||||
active: Effect.sync(() => new Set(executions.keys())),
|
||||
isActive,
|
||||
run,
|
||||
wake,
|
||||
interrupt: (key, reason, options) =>
|
||||
Effect.suspend(() => {
|
||||
const execution = executions.get(key)
|
||||
return interrupt(key, reason).pipe(
|
||||
Effect.tap(() =>
|
||||
options?.awaitSettlement && execution
|
||||
? Deferred.await(execution.done).pipe(Effect.ignoreCause)
|
||||
: Effect.void,
|
||||
),
|
||||
)
|
||||
}),
|
||||
awaitIdle,
|
||||
}
|
||||
})
|
||||
|
||||
+61
-53
@@ -39,7 +39,11 @@ type Data = {
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Editor> {
|
||||
readonly snapshot: (permissions?: Permission.Ruleset) => Effect.Effect<Snapshot>
|
||||
/** Filters by permissions, then lets `tool.snapshot` hooks hide tools for the given request. */
|
||||
readonly snapshot: (
|
||||
permissions?: Permission.Ruleset,
|
||||
request?: { readonly sessionID: SessionSchema.ID; readonly agent: Agent.ID },
|
||||
) => Effect.Effect<Snapshot>
|
||||
}
|
||||
|
||||
/** A local execution result after hooks and content normalization. */
|
||||
@@ -217,58 +221,62 @@ const layer = Layer.effect(
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
snapshot: Effect.fn("Tool.snapshot")((permissions) =>
|
||||
Effect.sync(() => {
|
||||
const active = new Map<string, Tool.Info>()
|
||||
const rules = permissions ?? []
|
||||
for (const [name, tool] of state.get().tools) {
|
||||
if (whollyDisabled(tool.options?.permission ?? name, rules)) continue
|
||||
active.set(name, tool)
|
||||
}
|
||||
const direct = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode === false))
|
||||
const codeModeTools = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode !== false))
|
||||
const namespaces = state.get().namespaces
|
||||
const codeModeInventory = { tools: codeModeTools, namespaces }
|
||||
const codeModeEnabled = !whollyDisabled("execute", rules)
|
||||
const codeModeTool = codeModeEnabled
|
||||
? CodeModeTool.create(codeModeInventory, (name, tool, input, context) =>
|
||||
beforeExecute(name, input, context).pipe(
|
||||
Effect.flatMap((event) => executeTool(tool, name, event.input, context)),
|
||||
),
|
||||
)
|
||||
: undefined
|
||||
const codeModeCatalog = codeModeEnabled ? CodeModeTool.catalog(codeModeInventory) : undefined
|
||||
return {
|
||||
...(codeModeCatalog === undefined ? {} : { codeModeCatalog }),
|
||||
definitions: [
|
||||
...Array.from(direct)
|
||||
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
||||
.map(([, tool]) => definition(tool)),
|
||||
...(codeModeTool ? [definition(codeModeTool)] : []),
|
||||
],
|
||||
execute: Effect.fnUntraced(function* (input: Parameters<Snapshot["execute"]>[0]) {
|
||||
const context: Tool.Context = {
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
messageID: input.messageID,
|
||||
id: Tool.CallID.make(input.call.id),
|
||||
progress: input.progress ?? (() => Effect.void),
|
||||
}
|
||||
const event = yield* beforeExecute(input.call.name, input.call.input, context)
|
||||
const requested = input.definitions?.get(event.tool)
|
||||
// Preserve session context removal and alias resolution, now after the repair hook.
|
||||
if (!requested && input.definitions && (direct.has(event.tool) || codeModeTool?.name === event.tool))
|
||||
return yield* new Tool.Error({ message: `Tool is not available for this request: ${event.tool}` })
|
||||
const name = requested?.name ?? event.tool
|
||||
if (name === "execute" && codeModeTool)
|
||||
return yield* executeTool(codeModeTool, name, event.input, context)
|
||||
const tool = direct.get(name)
|
||||
if (tool) return yield* executeTool(tool, name, event.input, context)
|
||||
return yield* new Tool.Error({ message: `Unknown tool: ${name}` })
|
||||
}),
|
||||
}
|
||||
}),
|
||||
),
|
||||
snapshot: Effect.fn("Tool.snapshot")(function* (permissions, request) {
|
||||
const rules = permissions ?? []
|
||||
const permitted = new Map(
|
||||
Array.from(state.get().tools).filter(
|
||||
([name, tool]) => !whollyDisabled(tool.options?.permission ?? name, rules),
|
||||
),
|
||||
)
|
||||
// Hooks see only permitted names and can only remove them, so a plugin cannot reveal a denied tool.
|
||||
const visible = request
|
||||
? new Set(
|
||||
(yield* hooks.trigger("tool", "snapshot", { ...request, tools: Array.from(permitted.keys()) })).tools,
|
||||
)
|
||||
: undefined
|
||||
const active = visible ? new Map(Array.from(permitted).filter(([name]) => visible.has(name))) : permitted
|
||||
const direct = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode === false))
|
||||
const codeModeTools = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode !== false))
|
||||
const namespaces = state.get().namespaces
|
||||
const codeModeInventory = { tools: codeModeTools, namespaces }
|
||||
const codeModeEnabled = !whollyDisabled("execute", rules)
|
||||
const codeModeTool = codeModeEnabled
|
||||
? CodeModeTool.create(codeModeInventory, (name, tool, input, context) =>
|
||||
beforeExecute(name, input, context).pipe(
|
||||
Effect.flatMap((event) => executeTool(tool, name, event.input, context)),
|
||||
),
|
||||
)
|
||||
: undefined
|
||||
const codeModeCatalog = codeModeEnabled ? CodeModeTool.catalog(codeModeInventory) : undefined
|
||||
return {
|
||||
...(codeModeCatalog === undefined ? {} : { codeModeCatalog }),
|
||||
definitions: [
|
||||
...Array.from(direct)
|
||||
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
||||
.map(([, tool]) => definition(tool)),
|
||||
...(codeModeTool ? [definition(codeModeTool)] : []),
|
||||
],
|
||||
execute: Effect.fnUntraced(function* (input: Parameters<Snapshot["execute"]>[0]) {
|
||||
const context: Tool.Context = {
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
messageID: input.messageID,
|
||||
id: Tool.CallID.make(input.call.id),
|
||||
progress: input.progress ?? (() => Effect.void),
|
||||
}
|
||||
const event = yield* beforeExecute(input.call.name, input.call.input, context)
|
||||
const requested = input.definitions?.get(event.tool)
|
||||
// Preserve session context removal and alias resolution, now after the repair hook.
|
||||
if (!requested && input.definitions && (direct.has(event.tool) || codeModeTool?.name === event.tool))
|
||||
return yield* new Tool.Error({ message: `Tool is not available for this request: ${event.tool}` })
|
||||
const name = requested?.name ?? event.tool
|
||||
if (name === "execute" && codeModeTool) return yield* executeTool(codeModeTool, name, event.input, context)
|
||||
const tool = direct.get(name)
|
||||
if (tool) return yield* executeTool(tool, name, event.input, context)
|
||||
return yield* new Tool.Error({ message: `Unknown tool: ${name}` })
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -53,6 +53,10 @@ The registry has no `Permission.Service` dependency and performs no execution au
|
||||
|
||||
Tool filtering is catalog visibility, not execution authorization. A call still executes the captured tool's leaf policy if it reaches execution.
|
||||
|
||||
## Per-request visibility
|
||||
|
||||
`Tool.Service.snapshot(permissions, request)` triggers the `tool.snapshot` plugin hook after permission filtering, passing the session and agent plus the permitted effective names. Hooks can only remove names; added names are ignored, so a plugin cannot reveal a denied tool. The result filters both the native definitions and the Code Mode inventory, so the rendered catalog and the `execute` runtime stay consistent. Registration remains Location-wide; this hook is how availability varies per session, such as a client connected to one session.
|
||||
|
||||
## Output
|
||||
|
||||
Built-ins return complete tool responses. `Tool.Snapshot.execute` is the local execution boundary. Generic output bounding is applied by the Session runner after execution.
|
||||
@@ -61,4 +65,4 @@ Producer capture remains local to producers. Shell stores combined process outpu
|
||||
|
||||
## Current Gaps
|
||||
|
||||
- Future Session-scoped registrations still need an explicit canonical registration design.
|
||||
- Future Session-scoped registrations still need an explicit canonical registration design. The `tool.snapshot` hook covers per-session visibility of Location-wide registrations, not per-session definitions.
|
||||
|
||||
@@ -11,6 +11,10 @@ import { WebSearch } from "../../websearch.js"
|
||||
export const name = "websearch"
|
||||
export const NO_RESULTS = "No search results found. Please try a different query."
|
||||
const providerSelectionLock = Semaphore.makeUnsafe(1)
|
||||
const httpErrors = new Map([
|
||||
[429, "Web search rate limited (HTTP 429)"],
|
||||
[401, "Web search authentication failed (HTTP 401)"],
|
||||
])
|
||||
|
||||
export const description = `Search the web using the user's selected search integration. Use this for current information beyond knowledge cutoff.
|
||||
|
||||
@@ -50,106 +54,100 @@ export const Plugin = {
|
||||
agent: context.agent,
|
||||
source: { type: "tool", messageID: context.messageID, id: context.id },
|
||||
})
|
||||
const search = (): Effect.Effect<Effect.Success<ReturnType<typeof ctx.websearch.query>>, unknown> =>
|
||||
websearch.default().pipe(
|
||||
Effect.flatMap((provider) => {
|
||||
if (!provider) return ctx.websearch.query(input)
|
||||
return context
|
||||
.progress({ provider: provider.id })
|
||||
.pipe(Effect.andThen(ctx.websearch.query({ ...input, providerID: provider.id })))
|
||||
}),
|
||||
Effect.catch((error) => {
|
||||
if (!Schema.is(WebSearch.ProviderRequiredError)(error)) return Effect.fail(error)
|
||||
return providerSelectionLock
|
||||
.withPermit(
|
||||
Effect.gen(function* () {
|
||||
if (yield* websearch.default()) return
|
||||
const providers = (yield* ctx.websearch.providers()).data
|
||||
const defaultProvider = providers[0]
|
||||
if (!defaultProvider) return yield* new WebSearch.ProviderRequiredError()
|
||||
const response = yield* forms.ask({
|
||||
sessionID: context.sessionID,
|
||||
title: "Web Search",
|
||||
metadata: { kind: "websearch.provider" },
|
||||
fields: [
|
||||
{
|
||||
key: "choice",
|
||||
description: "Allow OpenCode to search the web for up-to-date information?",
|
||||
type: "string",
|
||||
required: true,
|
||||
custom: false,
|
||||
options: [
|
||||
{
|
||||
value: "allow",
|
||||
label: `Allow search via ${providers.map((provider) => provider.name).join(", ")}`,
|
||||
},
|
||||
{
|
||||
value: "choose",
|
||||
label: "Choose another provider",
|
||||
},
|
||||
{ value: "disable", label: "Disable web search" },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
if (response.status === "cancelled")
|
||||
return yield* Effect.fail(new Error("Web search cancelled"))
|
||||
if (response.answer.choice === "disable") {
|
||||
yield* websearch.select(false)
|
||||
return yield* new WebSearch.DisabledError()
|
||||
}
|
||||
const selection =
|
||||
response.answer.choice === "choose"
|
||||
? yield* forms.ask({
|
||||
sessionID: context.sessionID,
|
||||
title: "Choose a web search provider",
|
||||
metadata: { kind: "websearch.provider" },
|
||||
fields: [
|
||||
{
|
||||
key: "provider",
|
||||
description: "Choose a provider for web search.",
|
||||
type: "string",
|
||||
required: true,
|
||||
custom: false,
|
||||
options: providers.map((provider) => ({
|
||||
value: provider.id,
|
||||
label: provider.name,
|
||||
})),
|
||||
},
|
||||
],
|
||||
})
|
||||
: undefined
|
||||
if (selection?.status === "cancelled")
|
||||
return yield* Effect.fail(new Error("Web search cancelled"))
|
||||
const providerID = selection?.answer.provider ?? "random"
|
||||
if (
|
||||
typeof providerID !== "string" ||
|
||||
(providerID !== "random" && !providers.some((provider) => provider.id === providerID))
|
||||
)
|
||||
return yield* new WebSearch.ProviderRequiredError()
|
||||
yield* websearch.select(providerID === "random" ? "random" : WebSearch.ID.make(providerID))
|
||||
if (providerID !== "random") return WebSearch.ID.make(providerID)
|
||||
return providers[Math.floor(Math.random() * providers.length)]?.id
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: "1 minute",
|
||||
orElse: () => Effect.fail(new Error("Web search cancelled")),
|
||||
}),
|
||||
Effect.flatMap((providerID) => {
|
||||
if (!providerID) return Effect.suspend(search)
|
||||
return context
|
||||
.progress({ provider: providerID })
|
||||
.pipe(Effect.andThen(ctx.websearch.query({ ...input, providerID })))
|
||||
}),
|
||||
)
|
||||
}),
|
||||
const search = (providerID?: WebSearch.ID) =>
|
||||
websearch.query(
|
||||
{ ...input, providerID },
|
||||
{
|
||||
sessionID: context.sessionID,
|
||||
onProvider: (provider) => context.progress({ provider: provider.id }),
|
||||
},
|
||||
)
|
||||
const result = yield* search()
|
||||
const result = yield* search().pipe(
|
||||
Effect.catchTag("WebSearch.ProviderRequired", () => {
|
||||
return providerSelectionLock
|
||||
.withPermit(
|
||||
Effect.gen(function* () {
|
||||
if (yield* websearch.default()) return
|
||||
const providers = (yield* ctx.websearch.providers()).data
|
||||
const defaultProvider = providers[0]
|
||||
if (!defaultProvider) return yield* new WebSearch.ProviderRequiredError()
|
||||
const response = yield* forms.ask({
|
||||
sessionID: context.sessionID,
|
||||
title: "Web Search",
|
||||
metadata: { kind: "websearch.provider" },
|
||||
fields: [
|
||||
{
|
||||
key: "choice",
|
||||
description: "Allow OpenCode to search the web for up-to-date information?",
|
||||
type: "string",
|
||||
required: true,
|
||||
custom: false,
|
||||
options: [
|
||||
{
|
||||
value: "allow",
|
||||
label: `Allow search via ${providers.map((provider) => provider.name).join(", ")}`,
|
||||
},
|
||||
{
|
||||
value: "choose",
|
||||
label: "Choose another provider",
|
||||
},
|
||||
{ value: "disable", label: "Disable web search" },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
if (response.status === "cancelled")
|
||||
return yield* Effect.fail(new Error("Web search cancelled"))
|
||||
if (response.answer.choice === "disable") {
|
||||
yield* websearch.select(false)
|
||||
return yield* new WebSearch.DisabledError()
|
||||
}
|
||||
const selection =
|
||||
response.answer.choice === "choose"
|
||||
? yield* forms.ask({
|
||||
sessionID: context.sessionID,
|
||||
title: "Choose a web search provider",
|
||||
metadata: { kind: "websearch.provider" },
|
||||
fields: [
|
||||
{
|
||||
key: "provider",
|
||||
description: "Choose a provider for web search.",
|
||||
type: "string",
|
||||
required: true,
|
||||
custom: false,
|
||||
options: providers.map((provider) => ({
|
||||
value: provider.id,
|
||||
label: provider.name,
|
||||
})),
|
||||
},
|
||||
],
|
||||
})
|
||||
: undefined
|
||||
if (selection?.status === "cancelled")
|
||||
return yield* Effect.fail(new Error("Web search cancelled"))
|
||||
const providerID = selection?.answer.provider ?? "random"
|
||||
if (providerID === "random") {
|
||||
yield* websearch.select("random")
|
||||
return
|
||||
}
|
||||
const provider = providers.find((provider) => provider.id === providerID)
|
||||
if (!provider) return yield* new WebSearch.ProviderRequiredError()
|
||||
yield* websearch.select(provider.id)
|
||||
return provider.id
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: "1 minute",
|
||||
orElse: () => Effect.fail(new Error("Web search cancelled")),
|
||||
}),
|
||||
Effect.flatMap(search),
|
||||
)
|
||||
}),
|
||||
)
|
||||
const output = {
|
||||
provider: result.data.providerID,
|
||||
results: result.data.results,
|
||||
provider: result.providerID,
|
||||
results: result.results,
|
||||
}
|
||||
const content = output.results.length
|
||||
? output.results
|
||||
@@ -168,28 +166,14 @@ export const Plugin = {
|
||||
const fallback = `Unable to search the web for ${input.query}`
|
||||
if (!Schema.is(WebSearch.RequestError)(error)) return new ToolFailure({ message: fallback, error })
|
||||
const status = HttpClientError.isHttpClientError(error.cause) ? error.cause.response?.status : undefined
|
||||
switch (status) {
|
||||
case 429:
|
||||
return new ToolFailure({
|
||||
message: "Web search rate limited (HTTP 429)",
|
||||
error,
|
||||
metadata: { provider: error.providerID },
|
||||
})
|
||||
case 401:
|
||||
return new ToolFailure({
|
||||
message: "Web search authentication failed (HTTP 401)",
|
||||
error,
|
||||
metadata: { provider: error.providerID },
|
||||
})
|
||||
case undefined:
|
||||
return new ToolFailure({ message: fallback, error, metadata: { provider: error.providerID } })
|
||||
default:
|
||||
return new ToolFailure({
|
||||
message: `Web search request failed (HTTP ${status})`,
|
||||
error,
|
||||
metadata: { provider: error.providerID },
|
||||
})
|
||||
}
|
||||
return new ToolFailure({
|
||||
message:
|
||||
status === undefined
|
||||
? fallback
|
||||
: (httpErrors.get(status) ?? `Web search request failed (HTTP ${status})`),
|
||||
error,
|
||||
metadata: { provider: error.providerID },
|
||||
})
|
||||
}),
|
||||
),
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
export * as ErrorSummary from "./error-summary.js"
|
||||
|
||||
import { Option, Schema } from "effect"
|
||||
|
||||
const decode = Schema.decodeUnknownOption(
|
||||
Schema.Struct({
|
||||
name: Schema.optional(Schema.String),
|
||||
_tag: Schema.optional(Schema.String),
|
||||
code: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
|
||||
errno: Schema.optional(Schema.Number),
|
||||
cause: Schema.optional(Schema.Unknown),
|
||||
}),
|
||||
)
|
||||
|
||||
/** Error messages, stacks and SQL parameters may contain credentials. Retain only diagnostic classifications. */
|
||||
export function from(error: unknown) {
|
||||
const errors: { type: string; code?: string | number; errno?: number }[] = []
|
||||
const seen = new Set<unknown>()
|
||||
while (error && !seen.has(error) && errors.length < 8) {
|
||||
seen.add(error)
|
||||
const result = decode(error)
|
||||
if (Option.isNone(result)) break
|
||||
errors.push({
|
||||
type: result.value._tag ?? (error instanceof Error ? error.name : result.value.name) ?? "unknown",
|
||||
code: result.value.code,
|
||||
errno: result.value.errno,
|
||||
})
|
||||
error = error instanceof Error ? error.cause : result.value.cause
|
||||
}
|
||||
return errors
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
export * as WebSearch from "./websearch.js"
|
||||
|
||||
import { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
import { Context, Effect, Layer, Option, Schema } from "effect"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionEvent } from "@opencode-ai/schema/session-event"
|
||||
import { Clock, Context, Effect, Layer, Option, Schema, Stream } from "effect"
|
||||
import { HttpClientError } from "effect/unstable/http"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Bus } from "./bus.js"
|
||||
import { KV } from "./kv.js"
|
||||
@@ -55,7 +58,13 @@ export interface Interface extends State.Transformable<Editor> {
|
||||
readonly providers: () => Effect.Effect<readonly Provider[]>
|
||||
readonly default: () => Effect.Effect<Provider | undefined, DisabledError>
|
||||
readonly select: (selection: Selection) => Effect.Effect<void>
|
||||
readonly query: (input: Input) => Effect.Effect<Response, Error>
|
||||
readonly query: (
|
||||
input: Input,
|
||||
options?: {
|
||||
readonly sessionID?: Session.ID
|
||||
readonly onProvider?: (provider: Provider) => Effect.Effect<void>
|
||||
},
|
||||
) => Effect.Effect<Response, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/WebSearch") {}
|
||||
@@ -79,6 +88,17 @@ const layer = Layer.effect(
|
||||
const bus = yield* Bus.Service
|
||||
const kv = yield* KV.Service
|
||||
const decodeResults = Schema.decodeUnknownEffect(Schema.Array(Result))
|
||||
const cooldowns = new Map<ID, { until: number; error: RequestError }>()
|
||||
const preferred = new Map<Session.ID | undefined, { provider?: ID }>()
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => preferred.clear()))
|
||||
yield* bus.subscribe([SessionEvent.Deleted, SessionEvent.Moved]).pipe(
|
||||
Stream.runForEach((event) =>
|
||||
Effect.sync(() => {
|
||||
preferred.delete(event.data.sessionID)
|
||||
}),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
const state = State.create<Data, Editor>({
|
||||
initial: () => ({ providers: new Map() }),
|
||||
editor: (editor) => ({
|
||||
@@ -96,26 +116,46 @@ const layer = Layer.effect(
|
||||
return provider ? Effect.succeed(provider) : Effect.fail(new ProviderNotFoundError({ providerID }))
|
||||
}
|
||||
|
||||
const defaultProvider = Effect.fn("WebSearch.default")(function* () {
|
||||
const selection = Effect.fn("WebSearch.selection")(function* () {
|
||||
const data = state.get()
|
||||
const stored = data.selection === undefined ? yield* kv.get(ProviderKey) : undefined
|
||||
if (
|
||||
data.selection === false ||
|
||||
data.selection === "random" ||
|
||||
(data.selection && data.providers.has(data.selection))
|
||||
)
|
||||
return data.selection
|
||||
const stored = yield* kv.get(ProviderKey)
|
||||
const decoded = Schema.decodeUnknownOption(Selection)(stored)
|
||||
if (stored !== undefined && Option.isNone(decoded)) yield* kv.remove(ProviderKey)
|
||||
const selection = data.selection ?? Option.getOrUndefined(decoded)
|
||||
if (selection === false) return yield* new DisabledError()
|
||||
if (selection === "random") {
|
||||
const providers = Array.from(data.providers.values())
|
||||
return providers[Math.floor(Math.random() * providers.length)]
|
||||
}
|
||||
return selection ? data.providers.get(selection) : undefined
|
||||
return Option.getOrUndefined(decoded)
|
||||
})
|
||||
|
||||
const resolve = Effect.fn("WebSearch.resolve")(function* (input: Input) {
|
||||
const randomProvider = (now: number, affinity: { provider?: ID }, attempted?: Set<ID>) => {
|
||||
const providers = state.get().providers
|
||||
if (input.providerID) return yield* requireProvider(providers, input.providerID)
|
||||
const provider = yield* defaultProvider()
|
||||
if (!provider) return yield* new ProviderRequiredError()
|
||||
cooldowns.forEach((cooldown, id) => {
|
||||
if (cooldown.until <= now || !providers.has(id)) cooldowns.delete(id)
|
||||
})
|
||||
const current = affinity.provider === undefined ? undefined : providers.get(affinity.provider)
|
||||
if (current && !cooldowns.has(current.id) && !attempted?.has(current.id)) return current
|
||||
const available = Array.from(providers.values()).filter(
|
||||
(provider) => !cooldowns.has(provider.id) && !attempted?.has(provider.id),
|
||||
)
|
||||
const provider = available[Math.floor(Math.random() * available.length)]
|
||||
if (provider) affinity.provider = provider.id
|
||||
return provider
|
||||
}
|
||||
|
||||
const defaultProvider = Effect.fn("WebSearch.default")(function* (choice: Selection | undefined) {
|
||||
if (choice === false) return yield* new DisabledError()
|
||||
if (choice === "random") {
|
||||
// Inspection must not select a provider or reopen consent when every provider is cooling down.
|
||||
const active = preferred.get(undefined)?.provider
|
||||
return (
|
||||
(active === undefined ? undefined : state.get().providers.get(active)) ??
|
||||
state.get().providers.values().next().value
|
||||
)
|
||||
}
|
||||
return choice ? state.get().providers.get(choice) : undefined
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
@@ -128,24 +168,58 @@ const layer = Layer.effect(
|
||||
})).toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
}),
|
||||
default: Effect.fn("WebSearch.defaultInfo")(function* () {
|
||||
const provider = yield* defaultProvider()
|
||||
const provider = yield* defaultProvider(yield* selection())
|
||||
return provider && { id: provider.id, name: provider.name }
|
||||
}),
|
||||
select: Effect.fn("WebSearch.select")(function* (selection) {
|
||||
yield* kv.set(ProviderKey, selection)
|
||||
}),
|
||||
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 })
|
||||
query: Effect.fn("WebSearch.query")(function* (input, options) {
|
||||
const choice = input.providerID ? undefined : yield* selection()
|
||||
let provider = input.providerID
|
||||
? yield* requireProvider(state.get().providers, input.providerID)
|
||||
: yield* defaultProvider(choice)
|
||||
if (!provider) return yield* new ProviderRequiredError()
|
||||
// Keep the cell for this query so deletion/movement cannot reinsert an in-flight session's entry.
|
||||
const affinity = preferred.get(options?.sessionID) ?? { provider: undefined }
|
||||
if (choice === "random") {
|
||||
preferred.set(options?.sessionID, affinity)
|
||||
provider = randomProvider(yield* Clock.currentTimeMillis, affinity) ?? provider
|
||||
}
|
||||
const attempted = new Set<ID>()
|
||||
while (true) {
|
||||
if (options?.onProvider) yield* options.onProvider({ id: provider.id, name: provider.name })
|
||||
let cooldown = choice === "random" ? cooldowns.get(provider.id) : undefined
|
||||
if (!cooldown || cooldown.until <= (yield* Clock.currentTimeMillis)) {
|
||||
attempted.add(provider.id)
|
||||
const result = yield* provider
|
||||
.execute({ query: input.query })
|
||||
.pipe(Effect.flatMap(decodeResults), Effect.result)
|
||||
if (result._tag === "Success") return new Response({ providerID: provider.id, results: result.success })
|
||||
const cause = result.failure
|
||||
const error = new RequestError({ providerID: provider.id, cause })
|
||||
if (choice !== "random" || !HttpClientError.isHttpClientError(cause) || cause.response?.status !== 429)
|
||||
return yield* error
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
cooldown = { until: now + cooldownMillis(cause.response.headers["retry-after"], now), error }
|
||||
cooldowns.set(provider.id, cooldown)
|
||||
}
|
||||
provider = randomProvider(yield* Clock.currentTimeMillis, affinity, attempted)
|
||||
if (!provider) return yield* cooldown.error
|
||||
}
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
function cooldownMillis(value: string | undefined, now: number) {
|
||||
if (!value?.trim()) return 60_000
|
||||
const seconds = Number(value)
|
||||
if (Number.isFinite(seconds)) return seconds >= 0 ? seconds * 1000 : 60_000
|
||||
const date = Date.parse(value)
|
||||
return Number.isFinite(date) ? Math.max(0, date - now) : 60_000
|
||||
}
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
|
||||
@@ -1,20 +1,36 @@
|
||||
export * as TestWebSearch from "./websearch"
|
||||
|
||||
import { Context, Deferred, Effect, Layer } from "effect"
|
||||
import { HttpClientError, HttpClientRequest, 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 { Bus } from "@opencode-ai/core/bus"
|
||||
import { KV } from "@opencode-ai/core/kv"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
|
||||
export interface Interface extends WebSearch.Interface {
|
||||
readonly queries: readonly WebSearch.Input[]
|
||||
readonly sessionIDs: readonly (Session.ID | undefined)[]
|
||||
/** Waits for query arrivals, not provider execution or query completion. */
|
||||
readonly wait: (count: number) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("test/WebSearch") {}
|
||||
|
||||
export function httpError(status = 429, retryAfter?: string, url = "https://search.example.com") {
|
||||
const request = HttpClientRequest.post(url)
|
||||
return new HttpClientError.HttpClientError({
|
||||
reason: new HttpClientError.StatusCodeError({
|
||||
request,
|
||||
response: HttpClientResponse.fromWeb(
|
||||
request,
|
||||
new Response(null, { status, headers: retryAfter === undefined ? {} : { "Retry-After": retryAfter } }),
|
||||
),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
// No providers are installed: tests register local executors through transform.
|
||||
// The normal Bus and KV implementations use the default in-memory database.
|
||||
export const layer = Layer.effectContext(
|
||||
@@ -22,6 +38,7 @@ export const layer = Layer.effectContext(
|
||||
const context = yield* Layer.build(AppNodeBuilder.build(LayerNode.group([WebSearch.node, Bus.node, KV.node])))
|
||||
const websearch = Context.get(context, WebSearch.Service)
|
||||
const queries: WebSearch.Input[] = []
|
||||
const sessionIDs: (Session.ID | undefined)[] = []
|
||||
let started = yield* Deferred.make<void>()
|
||||
const wait = (count: number): Effect.Effect<void> =>
|
||||
Effect.suspend(() =>
|
||||
@@ -30,13 +47,15 @@ export const layer = Layer.effectContext(
|
||||
const test = Service.of({
|
||||
...websearch,
|
||||
queries,
|
||||
sessionIDs,
|
||||
wait,
|
||||
query: Effect.fnUntraced(function* (input: WebSearch.Input) {
|
||||
query: Effect.fnUntraced(function* (input, options) {
|
||||
queries.push({ ...input })
|
||||
sessionIDs.push(options?.sessionID)
|
||||
const previous = started
|
||||
started = yield* Deferred.make<void>()
|
||||
yield* Deferred.succeed(previous, undefined)
|
||||
return yield* websearch.query(input)
|
||||
return yield* websearch.query(input, options)
|
||||
}),
|
||||
})
|
||||
return Context.add(context, WebSearch.Service, test).pipe(Context.add(Service, test))
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Context, Deferred, Duration, Effect, Fiber, Layer, LayerMap, RcMap, Schema } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Form } from "@opencode-ai/core/form"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationActivity } from "@opencode-ai/core/location-activity"
|
||||
import { LocationServiceMap, type LocationServices } from "@opencode-ai/core/location-services"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionRunner } from "@opencode-ai/core/session/runner/index"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
// Keep real execution ownership, location caching, forms, and eviction. The fixture
|
||||
// runner waits on a form instead of making a model request before asking a question.
|
||||
const locations = Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const map = yield* LayerMap.make(
|
||||
(ref: Location.Ref) =>
|
||||
// The fixture only exercises these three Location services.
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||
Layer.merge(
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of({
|
||||
directory: ref.directory,
|
||||
workspaceID: ref.workspaceID,
|
||||
project: { id: Project.ID.global, directory: ref.directory, canonical: ref.directory },
|
||||
}),
|
||||
),
|
||||
Layer.effect(
|
||||
SessionRunner.Service,
|
||||
Effect.gen(function* () {
|
||||
const forms = yield* Form.Service
|
||||
return SessionRunner.Service.of({
|
||||
drain: ({ sessionID }) =>
|
||||
forms
|
||||
.ask({
|
||||
sessionID,
|
||||
title: "Questions",
|
||||
fields: [{ key: "runtime", type: "string" }],
|
||||
})
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.as(SessionRunner.DrainResult.Complete()),
|
||||
Effect.onInterrupt(() => Effect.sleep("5 minutes")),
|
||||
),
|
||||
})
|
||||
}),
|
||||
),
|
||||
).pipe(
|
||||
Layer.provideMerge(Form.layer),
|
||||
Layer.provide(Layer.succeed(Bus.Service, bus)),
|
||||
Layer.fresh,
|
||||
) as unknown as Layer.Layer<LocationServices>,
|
||||
{ idleTimeToLive: Duration.infinity },
|
||||
)
|
||||
return {
|
||||
...map,
|
||||
get: (ref: Location.Ref) => map.get(LocationServiceMap.canonical(ref)),
|
||||
contextEffect: (ref: Location.Ref) => map.contextEffect(LocationServiceMap.canonical(ref)),
|
||||
contextEffectOption: (ref: Location.Ref) => map.contextEffectOption(LocationServiceMap.canonical(ref)),
|
||||
invalidate: (ref: Location.Ref) => map.invalidate(LocationServiceMap.canonical(ref)),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
Database.node,
|
||||
Bus.node,
|
||||
SessionStore.node,
|
||||
LocationServiceMap.node,
|
||||
SessionExecution.node,
|
||||
LocationActivity.node,
|
||||
]),
|
||||
[
|
||||
LocationServiceMap.node.replace(
|
||||
makeGlobalNode({
|
||||
service: LocationServiceMap.Service,
|
||||
layer: locations,
|
||||
deps: [Bus.node],
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
describe("LocationActivity eviction", () => {
|
||||
for (const [count, admission] of [
|
||||
[1, "none"],
|
||||
[2, "none"],
|
||||
[1, "other"],
|
||||
[1, "same"],
|
||||
] as const) {
|
||||
const newWork = admission !== "none"
|
||||
it.effect(
|
||||
`interrupts ${count} waiting executions before eviction (${admission} session admitted during cleanup)`,
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
const bus = yield* Bus.Service
|
||||
const map = yield* LocationServiceMap.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const sessionIDs = Array.from({ length: count }, (_, index) =>
|
||||
Session.ID.make(`ses_waiting_question_${index}`),
|
||||
)
|
||||
const newcomer = admission === "same" ? sessionIDs[0] : Session.ID.make("ses_new_question")
|
||||
const ref = LocationServiceMap.canonical({ directory: AbsolutePath.make("/project") })
|
||||
const idle = Location.Ref.make({ directory: ref.directory, workspaceID: Workspace.ID.make("wrk_idle") })
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: ref.directory, sandboxes: [] })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values(
|
||||
Array.from(new Set([...sessionIDs, newcomer]), (sessionID) => ({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "question",
|
||||
directory: ref.directory,
|
||||
title: "Waiting question",
|
||||
version: "test",
|
||||
})),
|
||||
)
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
const created = yield* Deferred.make<void>()
|
||||
const newCreated = yield* Deferred.make<void>()
|
||||
const pending: Form.Info[] = []
|
||||
const interrupted: SessionEvent.Execution.Interrupted["data"][] = []
|
||||
const unsubscribe = yield* bus.listen((event) =>
|
||||
Effect.gen(function* () {
|
||||
if (event.type === SessionEvent.Execution.Interrupted.type) {
|
||||
interrupted.push(Schema.decodeUnknownSync(SessionEvent.Execution.Interrupted.data)(event.data))
|
||||
}
|
||||
if (event.type !== Form.Event.Created.type) return
|
||||
pending.push(Schema.decodeUnknownSync(Form.Event.Created.data)(event.data).form)
|
||||
if (pending.length === count) yield* Deferred.succeed(created, undefined)
|
||||
if (pending.length > count) yield* Deferred.succeed(newCreated, undefined)
|
||||
}),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
const running = yield* Effect.forEach(sessionIDs, (sessionID) =>
|
||||
execution.resume(sessionID).pipe(Effect.exit, Effect.forkScoped),
|
||||
)
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.forEach([...sessionIDs, newcomer], (sessionID) => execution.interrupt(sessionID)).pipe(
|
||||
Effect.andThen(TestClock.adjust("5 minutes")),
|
||||
),
|
||||
)
|
||||
yield* Deferred.await(created)
|
||||
const context = yield* map.contextEffect(ref).pipe(Effect.scoped)
|
||||
const forms = Context.get(context, Form.Service)
|
||||
expect((yield* store.listSuspended()).toSorted()).toEqual(sessionIDs.toSorted())
|
||||
yield* Location.Service.pipe(Effect.provide(map.get(idle)), Effect.scoped)
|
||||
|
||||
// Human input produces no durable activity while the question is pending.
|
||||
yield* TestClock.adjust("1 minute")
|
||||
yield* TestClock.adjust("62 minutes")
|
||||
// Interruption has cancelled each question, but slow cleanup still owns the graph.
|
||||
expect(Array.from(yield* execution.active).toSorted()).toEqual(sessionIDs.toSorted())
|
||||
expect(Array.from(yield* RcMap.keys(map.rcMap))).toEqual([ref])
|
||||
expect(yield* forms.list()).toEqual([])
|
||||
for (const form of pending) expect(yield* forms.state(form.id)).toEqual({ status: "cancelled" })
|
||||
|
||||
if (newWork) {
|
||||
yield* execution.wake(newcomer)
|
||||
if (admission === "other") yield* Deferred.await(newCreated)
|
||||
}
|
||||
yield* TestClock.adjust("5 minutes")
|
||||
if (newWork) yield* Deferred.await(newCreated)
|
||||
const results = yield* Effect.forEach(running, Fiber.join)
|
||||
expect(results.every((exit) => exit._tag === "Failure")).toBe(true)
|
||||
expect(Array.from(yield* execution.active)).toEqual(newWork ? [newcomer] : [])
|
||||
expect(yield* store.listSuspended()).toEqual(newWork ? [newcomer] : [])
|
||||
expect(interrupted.toSorted((a, b) => a.sessionID.localeCompare(b.sessionID))).toEqual(
|
||||
sessionIDs.map((sessionID) => ({ sessionID, reason: "inactivity" })),
|
||||
)
|
||||
expect(Array.from(yield* RcMap.keys(map.rcMap))).toEqual(newWork ? [ref] : [])
|
||||
if (newWork) {
|
||||
expect(yield* forms.list({ sessionID: newcomer })).toEqual([pending[count]])
|
||||
if (admission === "same") {
|
||||
const later = LocationServiceMap.canonical({ directory: AbsolutePath.make("/later") })
|
||||
yield* Location.Service.pipe(Effect.provide(map.get(later)), Effect.scoped)
|
||||
yield* TestClock.adjust("30 minutes")
|
||||
// Keep fresh work active while a different graph reaches its own deadline.
|
||||
yield* bus.publish(SessionEvent.Execution.Started, { sessionID: newcomer }, { location: ref })
|
||||
yield* TestClock.adjust("32 minutes")
|
||||
expect(Array.from(yield* execution.active)).toEqual([newcomer])
|
||||
expect(Array.from(yield* RcMap.keys(map.rcMap))).toEqual([ref])
|
||||
}
|
||||
yield* execution.interrupt(newcomer)
|
||||
yield* TestClock.adjust("5 minutes")
|
||||
yield* execution.awaitIdle(newcomer)
|
||||
yield* TestClock.adjust("62 minutes")
|
||||
expect(yield* store.listSuspended()).toEqual([])
|
||||
expect(Array.from(yield* RcMap.keys(map.rcMap))).toEqual([])
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -17,24 +17,29 @@ interface WebSearchRequest {
|
||||
}
|
||||
|
||||
export const requests: WebSearchRequest[] = []
|
||||
export const signals: AbortSignal[] = []
|
||||
let responseBody = ""
|
||||
let responseStatus = 200
|
||||
|
||||
export function resetWebSearchFixture(body: string) {
|
||||
export function resetWebSearchFixture(body: string, status = 200) {
|
||||
requests.length = 0
|
||||
signals.length = 0
|
||||
responseBody = body
|
||||
responseStatus = status
|
||||
}
|
||||
|
||||
const http = Layer.succeed(
|
||||
HttpClient.HttpClient,
|
||||
HttpClient.make((request) =>
|
||||
HttpClient.make((request, _url, signal) =>
|
||||
Effect.sync(() => {
|
||||
signals.push(signal)
|
||||
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 }))
|
||||
return HttpClientResponse.fromWeb(request, new Response(responseBody, { status: responseStatus }))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ import { WebSearchFirecrawl } from "@opencode-ai/core/plugin/websearch/firecrawl
|
||||
import { WebSearchParallel } from "@opencode-ai/core/plugin/websearch/parallel"
|
||||
import { WebSearchTavily } from "@opencode-ai/core/plugin/websearch/tavily"
|
||||
import { host, integrationHost, webSearchHost } from "./host"
|
||||
import { requests, resetWebSearchFixture, webSearchIntegrationTest } from "./websearch-fixture"
|
||||
import { requests, signals, resetWebSearchFixture, webSearchIntegrationTest } from "./websearch-fixture"
|
||||
|
||||
beforeEach(() => {
|
||||
resetWebSearchFixture(
|
||||
@@ -30,6 +30,25 @@ beforeEach(() => {
|
||||
const it = webSearchIntegrationTest
|
||||
|
||||
describe("built-in web search providers", () => {
|
||||
;[WebSearchExa.Plugin, WebSearchParallel.Plugin, WebSearchFirecrawl.Plugin, WebSearchTavily.Plugin].forEach(
|
||||
(plugin) => {
|
||||
it.effect(`releases rate-limited HTTP requests for ${plugin.id} before caching their errors`, () =>
|
||||
Effect.gen(function* () {
|
||||
resetWebSearchFixture("Rate limited", 429)
|
||||
const integrations = yield* Integration.Service
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* plugin.effect(
|
||||
host({ integration: integrationHost(integrations), websearch: webSearchHost(websearch) }),
|
||||
)
|
||||
yield* websearch.select("random")
|
||||
expect(yield* websearch.query({ query: "limited" }).pipe(Effect.flip)).toBeInstanceOf(WebSearch.RequestError)
|
||||
expect(signals).toHaveLength(1)
|
||||
expect(signals[0]?.aborted).toBe(true)
|
||||
}),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
it.effect("registers a provider without an integration", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
|
||||
@@ -706,6 +706,49 @@ describe("Tool", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("hides tools per request through the snapshot hook after permission filtering", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
yield* transform(service, { question: make(), edit: make() }, { codemode: false })
|
||||
yield* transform(service, { open: make(), list: make() }, { namespace: "browser" })
|
||||
const seen: string[][] = []
|
||||
const hidden = Session.ID.make("ses_without_browser")
|
||||
yield* hooks.register("tool", "snapshot", (event) =>
|
||||
Effect.sync(() => {
|
||||
seen.push(event.tools)
|
||||
event.tools = [...event.tools.filter((name) => !name.startsWith("browser_")), "invented"]
|
||||
if (event.sessionID !== hidden) event.tools.push("browser_open")
|
||||
}),
|
||||
)
|
||||
const names = (snapshot: Tool.Snapshot) => ({
|
||||
direct: snapshot.definitions.map((tool) => tool.name),
|
||||
codemode: codeModeListings(snapshot.codeModeCatalog!).map((tool) => tool.path),
|
||||
})
|
||||
|
||||
const attached = yield* service.snapshot([{ action: "edit", resource: "*", effect: "deny" }], {
|
||||
sessionID,
|
||||
agent: identity.agent,
|
||||
})
|
||||
expect(seen).toEqual([["question", "browser_open", "browser_list"]])
|
||||
expect(names(attached)).toEqual({ direct: ["question", "execute"], codemode: ["browser.open"] })
|
||||
|
||||
const detached = yield* service.snapshot(undefined, { sessionID: hidden, agent: identity.agent })
|
||||
expect(names(detached)).toEqual({ direct: ["edit", "question", "execute"], codemode: [] })
|
||||
expect((yield* detached.execute(call("edit"))).output).toEqual({ text: "edit" })
|
||||
const result = yield* detached.execute({
|
||||
...call("execute"),
|
||||
call: { type: "tool-call", id: "hidden-codemode", name: "execute", input: { code: "return tools.browser" } },
|
||||
})
|
||||
expect(result.output).toMatchObject({ error: true })
|
||||
|
||||
expect(names(yield* service.snapshot())).toEqual({
|
||||
direct: ["edit", "question", "execute"],
|
||||
codemode: ["browser.list", "browser.open"],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps permission options isolated between registrations", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import type { HttpClientError } from "effect/unstable/http"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
@@ -131,10 +131,11 @@ describe("WebSearchTool registration", () => {
|
||||
expect(fixture.websearch.queries).toEqual([
|
||||
{
|
||||
query: "effect typescript",
|
||||
providerID: WebSearch.ID.make("exa"),
|
||||
providerID: undefined,
|
||||
},
|
||||
])
|
||||
expect(fixture.events).toEqual(["permission", "query"])
|
||||
expect(fixture.websearch.sessionIDs).toEqual([sessionID])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -215,7 +216,7 @@ describe("WebSearchTool registration", () => {
|
||||
})
|
||||
expect(first.status).toBe("completed")
|
||||
expect(["exa", "parallel"]).toContain(first.metadata?.provider)
|
||||
expect(first.metadata?.provider).toBe(fixture.websearch.queries[1]?.providerID)
|
||||
expect(fixture.websearch.sessionIDs).toEqual([sessionID, sessionID])
|
||||
expect(yield* fixture.kv.get(WebSearch.ProviderKey)).toBe("random")
|
||||
expect(fixture.websearch.queries).toHaveLength(2)
|
||||
expect(fixture.formRequests).toEqual([
|
||||
@@ -253,12 +254,31 @@ describe("WebSearchTool registration", () => {
|
||||
})
|
||||
expect(second.status).toBe("completed")
|
||||
expect(["exa", "parallel"]).toContain(second.metadata?.provider)
|
||||
expect(second.metadata?.provider).toBe(fixture.websearch.queries[2]?.providerID)
|
||||
expect(second.metadata?.provider).toBe(first.metadata?.provider)
|
||||
expect(fixture.formRequests).toHaveLength(1)
|
||||
expect(fixture.websearch.queries).toHaveLength(3)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("honors automatic consent when the configured provider is unavailable", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* setup
|
||||
yield* fixture.websearch.transform((editor) => editor.default.set(WebSearch.ID.make("missing")))
|
||||
fixture.formResponse = { status: "answered", answer: { choice: "allow" } }
|
||||
const result = yield* executeTool(fixture.registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-missing", name: "websearch", input: { query: "effect" } },
|
||||
})
|
||||
expect(result.status).toBe("completed")
|
||||
expect(yield* fixture.kv.get(WebSearch.ProviderKey)).toBe("random")
|
||||
expect(result.metadata?.provider).toBe(
|
||||
(yield* fixture.websearch.query({ query: "next" }, { sessionID })).providerID,
|
||||
)
|
||||
expect(fixture.formRequests).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("asks a second form when choosing another provider", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* setup
|
||||
@@ -347,6 +367,67 @@ describe("WebSearchTool registration", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps provider progress, output, and metadata accurate across automatic failover", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* setup
|
||||
yield* fixture.websearch.select("random")
|
||||
const first = (yield* fixture.websearch.query({ query: "seed" }, { sessionID })).providerID
|
||||
yield* fixture.websearch.transform((editor) =>
|
||||
editor.add({
|
||||
id: first,
|
||||
name: first,
|
||||
execute: () => Effect.fail(TestWebSearch.httpError()),
|
||||
}),
|
||||
)
|
||||
const progress: Tool.Metadata[] = []
|
||||
const tools = yield* fixture.registry.snapshot()
|
||||
const result = yield* tools.execute({
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-failover", name: "websearch", input: { query: "effect" } },
|
||||
progress: (metadata) =>
|
||||
Effect.sync(() => {
|
||||
progress.push(metadata)
|
||||
}),
|
||||
})
|
||||
const replacement = WebSearch.ID.make(first === "exa" ? "parallel" : "exa")
|
||||
expect(progress).toEqual([{ provider: first }, { provider: replacement }])
|
||||
expect(result).toMatchObject({
|
||||
output: { provider: replacement, results: fixture.results },
|
||||
metadata: { provider: replacement },
|
||||
})
|
||||
expect(fixture.formRequests).toEqual([])
|
||||
expect((yield* fixture.websearch.query({ query: "next" }, { sessionID })).providerID).toBe(replacement)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not reopen consent when all automatic providers are cooling down", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* setup
|
||||
yield* fixture.websearch.select("random")
|
||||
fixture.error = TestWebSearch.httpError()
|
||||
const tools = yield* fixture.registry.snapshot()
|
||||
yield* Effect.forEach(["first", "cooling"], (query) =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* tools
|
||||
.execute({
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: `call-${query}`, name: "websearch", input: { query } },
|
||||
})
|
||||
.pipe(Effect.flip)
|
||||
expect(toSessionError(error)).toEqual({
|
||||
type: "tool.execution",
|
||||
message: "Web search rate limited (HTTP 429)",
|
||||
})
|
||||
expect(error.metadata).toMatchObject({ provider: expect.stringMatching(/^(exa|parallel)$/) })
|
||||
}),
|
||||
)
|
||||
expect(fixture.events.filter((event) => event === "query")).toHaveLength(2)
|
||||
expect(fixture.formRequests).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reports safe HTTP failures with the attempted provider", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* setup
|
||||
@@ -362,14 +443,7 @@ describe("WebSearchTool registration", () => {
|
||||
],
|
||||
({ status, message }, index) =>
|
||||
Effect.gen(function* () {
|
||||
const request = HttpClientRequest.post("https://mcp.exa.ai/mcp?exaApiKey=secret")
|
||||
fixture.error = new HttpClientError.HttpClientError({
|
||||
reason: new HttpClientError.StatusCodeError({
|
||||
request,
|
||||
response: HttpClientResponse.fromWeb(request, new Response(null, { status })),
|
||||
description: "non 2xx status code",
|
||||
}),
|
||||
})
|
||||
fixture.error = TestWebSearch.httpError(status, undefined, "https://mcp.exa.ai/mcp?exaApiKey=secret")
|
||||
const progress: Tool.Metadata[] = []
|
||||
const error = yield* tools
|
||||
.execute({
|
||||
|
||||
@@ -1,24 +1,34 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Scope } from "effect"
|
||||
import { Deferred, Effect, Exit, Fiber, Scope } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { KV } from "@opencode-ai/core/kv"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionEvent } from "@opencode-ai/schema/session-event"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { AbsolutePath } from "@opencode-ai/schema/schema"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { TestWebSearch } from "./lib/websearch"
|
||||
|
||||
const it = testEffect(TestWebSearch.layer)
|
||||
const firstSession = Session.ID.make("ses_search_first")
|
||||
const secondSession = Session.ID.make("ses_search_second")
|
||||
|
||||
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((editor) => {
|
||||
const failure: { cause?: unknown } = {}
|
||||
const registration = yield* websearch.transform((editor) => {
|
||||
editor.add({
|
||||
id: providerID,
|
||||
name: id.toUpperCase(),
|
||||
execute: (input) =>
|
||||
Effect.sync(() => {
|
||||
Effect.gen(function* () {
|
||||
calls.push(input)
|
||||
if (failure.cause !== undefined) return yield* Effect.fail(failure.cause)
|
||||
return [
|
||||
{
|
||||
url: `https://${id}.example.com`,
|
||||
@@ -30,7 +40,7 @@ const register = (id: string) =>
|
||||
}),
|
||||
})
|
||||
})
|
||||
return { providerID, calls }
|
||||
return { providerID, calls, failure, dispose: registration.dispose }
|
||||
})
|
||||
|
||||
describe("WebSearch", () => {
|
||||
@@ -137,17 +147,445 @@ describe("WebSearch", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("chooses a registered provider for random selection", () =>
|
||||
it.effect("keeps the random provider across queries, default lookups, and reloads", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* register("exa")
|
||||
yield* register("parallel")
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* websearch.transform((editor) => editor.default.set("random"))
|
||||
|
||||
expect(["exa", "parallel"]).toContain((yield* websearch.query({ query: "random" })).providerID)
|
||||
const first = yield* websearch.query({ query: "first" })
|
||||
expect(["exa", "parallel"]).toContain(first.providerID)
|
||||
expect((yield* websearch.default())?.id).toBe(first.providerID)
|
||||
yield* websearch.reload()
|
||||
const results = yield* Effect.all(
|
||||
Array.from({ length: 10 }, () => websearch.query({ query: "next" })),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
expect(results.every((result) => result.providerID === first.providerID)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves persisted random selection and keeps its provider", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* register("exa")
|
||||
yield* register("parallel")
|
||||
const websearch = yield* WebSearch.Service
|
||||
const kv = yield* KV.Service
|
||||
yield* kv.set(WebSearch.ProviderKey, "random")
|
||||
const first = yield* websearch.query({ query: "legacy" })
|
||||
expect((yield* websearch.query({ query: "sticky" })).providerID).toBe(first.providerID)
|
||||
yield* websearch.select("random")
|
||||
expect(yield* kv.get(WebSearch.ProviderKey)).toBe("random")
|
||||
expect((yield* websearch.query({ query: "canonical" })).providerID).toBe(first.providerID)
|
||||
}),
|
||||
)
|
||||
it.effect("fails over on rate limits with random and keeps the replacement after cooldown", () =>
|
||||
Effect.gen(function* () {
|
||||
const exa = yield* register("exa")
|
||||
const parallel = yield* register("parallel")
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* websearch.transform((editor) => editor.default.set("random"))
|
||||
const first = yield* websearch.query({ query: "first" })
|
||||
const limited = first.providerID === exa.providerID ? exa : parallel
|
||||
const replacement = first.providerID === exa.providerID ? parallel : exa
|
||||
limited.failure.cause = TestWebSearch.httpError()
|
||||
const progress: WebSearch.ID[] = []
|
||||
expect(
|
||||
(yield* websearch.query(
|
||||
{ query: "retry" },
|
||||
{
|
||||
onProvider: (provider) =>
|
||||
Effect.sync(() => {
|
||||
progress.push(provider.id)
|
||||
}),
|
||||
},
|
||||
)).providerID,
|
||||
).toBe(replacement.providerID)
|
||||
expect(progress).toEqual([limited.providerID, replacement.providerID])
|
||||
expect(limited.calls.at(-1)).toEqual({ query: "retry" })
|
||||
expect(replacement.calls).toEqual([{ query: "retry" }])
|
||||
|
||||
limited.failure.cause = undefined
|
||||
yield* TestClock.adjust("59 seconds")
|
||||
expect((yield* websearch.query({ query: "cooling" })).providerID).toBe(replacement.providerID)
|
||||
expect(limited.calls).toHaveLength(2)
|
||||
yield* TestClock.adjust("1 second")
|
||||
expect((yield* websearch.query({ query: "still sticky" })).providerID).toBe(replacement.providerID)
|
||||
replacement.failure.cause = TestWebSearch.httpError()
|
||||
expect((yield* websearch.query({ query: "recovered" })).providerID).toBe(limited.providerID)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reselects when a concurrent query cools down the provider while progress is pending", () =>
|
||||
Effect.gen(function* () {
|
||||
const exa = yield* register("exa")
|
||||
const parallel = yield* register("parallel")
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* websearch.select("random")
|
||||
yield* websearch.query({ query: "seed" })
|
||||
const first = yield* websearch.default()
|
||||
if (!first) return yield* Effect.die("Expected an automatic provider")
|
||||
const limited = first.id === exa.providerID ? exa : parallel
|
||||
const replacement = first.id === exa.providerID ? parallel : exa
|
||||
const paused = yield* Deferred.make<void>()
|
||||
const resume = yield* Deferred.make<void>()
|
||||
const progress: WebSearch.ID[] = []
|
||||
const pending = yield* websearch
|
||||
.query(
|
||||
{ query: "pending" },
|
||||
{
|
||||
onProvider: (provider) =>
|
||||
Effect.gen(function* () {
|
||||
progress.push(provider.id)
|
||||
if (provider.id !== first.id) return
|
||||
yield* Deferred.succeed(paused, undefined)
|
||||
yield* Deferred.await(resume)
|
||||
}),
|
||||
},
|
||||
)
|
||||
.pipe(Effect.forkChild)
|
||||
yield* Deferred.await(paused)
|
||||
limited.failure.cause = TestWebSearch.httpError()
|
||||
expect((yield* websearch.query({ query: "trigger" })).providerID).toBe(replacement.providerID)
|
||||
yield* Deferred.succeed(resume, undefined)
|
||||
expect((yield* Fiber.join(pending)).providerID).toBe(replacement.providerID)
|
||||
expect(progress).toEqual([limited.providerID, replacement.providerID])
|
||||
expect(limited.calls).toEqual([{ query: "seed" }, { query: "trigger" }])
|
||||
expect(replacement.calls).toEqual([{ query: "trigger" }, { query: "pending" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails promptly when all providers are cooling down without asking for a provider", () =>
|
||||
Effect.gen(function* () {
|
||||
const providers = [yield* register("exa"), yield* register("parallel"), yield* register("tavily")]
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* websearch.select("random")
|
||||
providers.forEach((provider) => {
|
||||
provider.failure.cause = TestWebSearch.httpError()
|
||||
})
|
||||
expect(yield* websearch.query({ query: "limited" }).pipe(Effect.flip)).toBeInstanceOf(WebSearch.RequestError)
|
||||
expect(providers.map((provider) => provider.calls.length)).toEqual([1, 1, 1])
|
||||
expect(yield* websearch.default()).toBeDefined()
|
||||
expect(yield* websearch.query({ query: "still limited" }).pipe(Effect.flip)).toBeInstanceOf(
|
||||
WebSearch.RequestError,
|
||||
)
|
||||
expect(providers.map((provider) => provider.calls.length)).toEqual([1, 1, 1])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("tries each provider only once per query even with a zero cooldown", () =>
|
||||
Effect.gen(function* () {
|
||||
const providers = [yield* register("exa"), yield* register("parallel")]
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* websearch.select("random")
|
||||
providers.forEach((provider) => {
|
||||
provider.failure.cause = TestWebSearch.httpError(429, "0")
|
||||
})
|
||||
expect(yield* websearch.query({ query: "limited" }).pipe(Effect.flip)).toBeInstanceOf(WebSearch.RequestError)
|
||||
expect(providers.map((provider) => provider.calls.length)).toEqual([1, 1])
|
||||
}),
|
||||
)
|
||||
;[
|
||||
{ header: "120", millis: 120_000 },
|
||||
{ header: "Thu, 01 Jan 1970 00:02:00 GMT", millis: 120_000 },
|
||||
{ header: undefined, millis: 60_000 },
|
||||
{ header: "invalid", millis: 60_000 },
|
||||
{ header: "", millis: 60_000 },
|
||||
{ header: "-1", millis: 60_000 },
|
||||
].forEach(({ header, millis }) => {
|
||||
it.effect(`respects Retry-After ${JSON.stringify(header)} and recovers after cooldown`, () =>
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* register("exa")
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* websearch.select("random")
|
||||
provider.failure.cause = TestWebSearch.httpError(429, header)
|
||||
expect(yield* websearch.query({ query: "limited" }).pipe(Effect.flip)).toBeInstanceOf(WebSearch.RequestError)
|
||||
provider.failure.cause = undefined
|
||||
yield* TestClock.adjust(millis - 1)
|
||||
expect(yield* websearch.query({ query: "early" }).pipe(Effect.flip)).toBeInstanceOf(WebSearch.RequestError)
|
||||
expect(provider.calls).toHaveLength(1)
|
||||
yield* TestClock.adjust(1)
|
||||
expect((yield* websearch.query({ query: "recovered" })).providerID).toBe(provider.providerID)
|
||||
expect(provider.calls).toHaveLength(2)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("does not rotate or cool down providers for other failures", () =>
|
||||
Effect.gen(function* () {
|
||||
const exa = yield* register("exa")
|
||||
const parallel = yield* register("parallel")
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* websearch.select("random")
|
||||
const first = yield* websearch.query({ query: "first" })
|
||||
const provider = first.providerID === exa.providerID ? exa : parallel
|
||||
yield* Effect.forEach(
|
||||
[TestWebSearch.httpError(401), TestWebSearch.httpError(500), new Error("timeout")],
|
||||
(cause) =>
|
||||
Effect.gen(function* () {
|
||||
provider.failure.cause = cause
|
||||
expect(yield* websearch.query({ query: "failure" }).pipe(Effect.flip)).toMatchObject({
|
||||
providerID: first.providerID,
|
||||
cause,
|
||||
})
|
||||
expect((yield* websearch.default())?.id).toBe(first.providerID)
|
||||
}),
|
||||
)
|
||||
provider.failure.cause = undefined
|
||||
expect((yield* websearch.query({ query: "recovered" })).providerID).toBe(first.providerID)
|
||||
expect((first.providerID === exa.providerID ? parallel : exa).calls).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not fail over fixed or explicitly requested providers", () =>
|
||||
Effect.gen(function* () {
|
||||
const exa = yield* register("exa")
|
||||
const parallel = yield* register("parallel")
|
||||
const websearch = yield* WebSearch.Service
|
||||
exa.failure.cause = TestWebSearch.httpError()
|
||||
yield* websearch.select(exa.providerID)
|
||||
expect(yield* websearch.query({ query: "fixed" }).pipe(Effect.flip)).toMatchObject({ providerID: exa.providerID })
|
||||
yield* websearch.select("random")
|
||||
expect(yield* websearch.query({ query: "explicit", providerID: exa.providerID }).pipe(Effect.flip)).toMatchObject(
|
||||
{
|
||||
providerID: exa.providerID,
|
||||
},
|
||||
)
|
||||
expect(exa.calls).toHaveLength(2)
|
||||
expect(parallel.calls).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reselects when the active provider is removed", () =>
|
||||
Effect.gen(function* () {
|
||||
const exa = yield* register("exa")
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* websearch.select("random")
|
||||
expect((yield* websearch.query({ query: "first" })).providerID).toBe(exa.providerID)
|
||||
const parallel = yield* register("parallel")
|
||||
expect((yield* websearch.query({ query: "still sticky" })).providerID).toBe(exa.providerID)
|
||||
yield* exa.dispose
|
||||
expect((yield* websearch.query({ query: "removed" })).providerID).toBe(parallel.providerID)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses updated registrations for the sticky provider", () =>
|
||||
Effect.gen(function* () {
|
||||
const exa = yield* register("exa")
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* websearch.select("random")
|
||||
expect((yield* websearch.query({ query: "original" })).results).toHaveLength(1)
|
||||
const updated = yield* websearch.transform((editor) =>
|
||||
editor.add({
|
||||
id: exa.providerID,
|
||||
name: "Updated Exa",
|
||||
execute: () => Effect.succeed([]),
|
||||
}),
|
||||
)
|
||||
expect(yield* websearch.default()).toEqual({ id: exa.providerID, name: "Updated Exa" })
|
||||
expect((yield* websearch.query({ query: "updated" })).results).toEqual([])
|
||||
yield* updated.dispose
|
||||
expect((yield* websearch.query({ query: "restored" })).results).toHaveLength(1)
|
||||
expect(exa.calls).toEqual([{ query: "original" }, { query: "restored" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps independent session affinities across parallel initial and subsequent searches", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* register("exa")
|
||||
yield* register("parallel")
|
||||
yield* register("tavily")
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* websearch.select("random")
|
||||
const results = yield* Effect.forEach(
|
||||
[firstSession, secondSession],
|
||||
(sessionID) =>
|
||||
Effect.all(
|
||||
Array.from({ length: 8 }, () => websearch.query({ query: "parallel" }, { sessionID })),
|
||||
{
|
||||
concurrency: "unbounded",
|
||||
},
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
expect(results.map((group) => new Set(group.map((result) => result.providerID)).size)).toEqual([1, 1])
|
||||
expect((yield* websearch.query({ query: "later" }, { sessionID: firstSession })).providerID).toBe(
|
||||
results[0]?.[0]?.providerID,
|
||||
)
|
||||
expect((yield* websearch.query({ query: "later" }, { sessionID: secondSession })).providerID).toBe(
|
||||
results[1]?.[0]?.providerID,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not overwrite peer or Location affinity when a session switches providers", () =>
|
||||
Effect.gen(function* () {
|
||||
const exa = yield* register("exa")
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* websearch.select("random")
|
||||
yield* websearch.query({ query: "location" })
|
||||
yield* websearch.query({ query: "first" }, { sessionID: firstSession })
|
||||
yield* websearch.query({ query: "second" }, { sessionID: secondSession })
|
||||
const parallel = yield* register("parallel")
|
||||
exa.failure.cause = TestWebSearch.httpError()
|
||||
expect((yield* websearch.query({ query: "switch" }, { sessionID: firstSession })).providerID).toBe(
|
||||
parallel.providerID,
|
||||
)
|
||||
// Even while Exa is cooling down, inspection must not reroute any caller.
|
||||
expect((yield* websearch.default())?.id).toBe(exa.providerID)
|
||||
exa.failure.cause = undefined
|
||||
yield* TestClock.adjust("1 minute")
|
||||
expect((yield* websearch.query({ query: "peer" }, { sessionID: secondSession })).providerID).toBe(exa.providerID)
|
||||
expect((yield* websearch.query({ query: "location" })).providerID).toBe(exa.providerID)
|
||||
expect((yield* websearch.query({ query: "sticky replacement" }, { sessionID: firstSession })).providerID).toBe(
|
||||
parallel.providerID,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("shares cooldowns without sending a peer back to the rate-limited provider", () =>
|
||||
Effect.gen(function* () {
|
||||
const exa = yield* register("exa")
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* websearch.select("random")
|
||||
yield* websearch.query({ query: "first" }, { sessionID: firstSession })
|
||||
yield* websearch.query({ query: "second" }, { sessionID: secondSession })
|
||||
const parallel = yield* register("parallel")
|
||||
exa.failure.cause = TestWebSearch.httpError(429, "120")
|
||||
yield* websearch.query({ query: "switch" }, { sessionID: firstSession })
|
||||
expect((yield* websearch.query({ query: "peer" }, { sessionID: secondSession })).providerID).toBe(
|
||||
parallel.providerID,
|
||||
)
|
||||
expect(exa.calls).toHaveLength(3)
|
||||
exa.failure.cause = undefined
|
||||
yield* TestClock.adjust("2 minutes")
|
||||
expect((yield* websearch.query({ query: "sticky peer" }, { sessionID: secondSession })).providerID).toBe(
|
||||
parallel.providerID,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("converges overlapping session failures and ignores a late success on the old provider", () =>
|
||||
Effect.gen(function* () {
|
||||
const websearch = yield* WebSearch.Service
|
||||
const arrived = yield* Deferred.make<void>()
|
||||
const failures = yield* Deferred.make<void>()
|
||||
const lateStarted = yield* Deferred.make<void>()
|
||||
const lateRelease = yield* Deferred.make<void>()
|
||||
const calls: string[] = []
|
||||
yield* websearch.transform((editor) =>
|
||||
editor.add({
|
||||
id: WebSearch.ID.make("exa"),
|
||||
name: "Exa",
|
||||
execute: (input) =>
|
||||
Effect.gen(function* () {
|
||||
if (input.query === "seed") return []
|
||||
if (input.query === "late") {
|
||||
yield* Deferred.succeed(lateStarted, undefined)
|
||||
yield* Deferred.await(lateRelease)
|
||||
return []
|
||||
}
|
||||
calls.push(input.query)
|
||||
if (calls.length === 3) yield* Deferred.succeed(arrived, undefined)
|
||||
yield* Deferred.await(failures)
|
||||
return yield* TestWebSearch.httpError()
|
||||
}),
|
||||
}),
|
||||
)
|
||||
yield* websearch.select("random")
|
||||
yield* websearch.query({ query: "seed" }, { sessionID: firstSession })
|
||||
const late = yield* websearch.query({ query: "late" }, { sessionID: firstSession }).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(lateStarted)
|
||||
yield* register("parallel")
|
||||
yield* register("tavily")
|
||||
const pending = yield* Effect.all(
|
||||
Array.from({ length: 3 }, (_, index) =>
|
||||
websearch.query({ query: `fail-${index}` }, { sessionID: firstSession }),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(arrived)
|
||||
yield* Deferred.succeed(failures, undefined)
|
||||
const results = yield* Fiber.join(pending)
|
||||
expect(new Set(results.map((result) => result.providerID)).size).toBe(1)
|
||||
expect(results[0]?.providerID).not.toBe(WebSearch.ID.make("exa"))
|
||||
yield* Deferred.succeed(lateRelease, undefined)
|
||||
expect((yield* Fiber.join(late)).providerID).toBe(WebSearch.ID.make("exa"))
|
||||
expect((yield* websearch.query({ query: "later" }, { sessionID: firstSession })).providerID).toBe(
|
||||
results[0]?.providerID,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps fixed and explicit providers pinned with session context", () =>
|
||||
Effect.gen(function* () {
|
||||
const exa = yield* register("exa")
|
||||
const parallel = yield* register("parallel")
|
||||
const websearch = yield* WebSearch.Service
|
||||
exa.failure.cause = TestWebSearch.httpError()
|
||||
yield* websearch.select(exa.providerID)
|
||||
expect(yield* websearch.query({ query: "fixed" }, { sessionID: firstSession }).pipe(Effect.flip)).toMatchObject({
|
||||
providerID: exa.providerID,
|
||||
})
|
||||
yield* websearch.select("random")
|
||||
expect(
|
||||
yield* websearch
|
||||
.query({ query: "explicit", providerID: exa.providerID }, { sessionID: firstSession })
|
||||
.pipe(Effect.flip),
|
||||
).toMatchObject({ providerID: exa.providerID })
|
||||
expect(parallel.calls).toEqual([])
|
||||
}),
|
||||
)
|
||||
;["delete", "move"].forEach((operation) => {
|
||||
it.effect(`forgets affinity on session ${operation} without retaining it through an in-flight query`, () =>
|
||||
Effect.gen(function* () {
|
||||
const exa = yield* register("exa")
|
||||
const websearch = yield* WebSearch.Service
|
||||
const bus = yield* Bus.Service
|
||||
yield* websearch.select("random")
|
||||
yield* websearch.query({ query: "seed" }, { sessionID: firstSession })
|
||||
const started = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const pending = yield* websearch
|
||||
.query(
|
||||
{ query: "pending" },
|
||||
{
|
||||
sessionID: firstSession,
|
||||
onProvider: (provider) =>
|
||||
provider.id === exa.providerID
|
||||
? Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release)))
|
||||
: Effect.void,
|
||||
},
|
||||
)
|
||||
.pipe(Effect.forkChild)
|
||||
yield* Deferred.await(started)
|
||||
yield* operation === "delete"
|
||||
? bus.publish(SessionEvent.Deleted, { sessionID: firstSession })
|
||||
: bus.publish(SessionEvent.Moved, {
|
||||
sessionID: firstSession,
|
||||
location: { directory: AbsolutePath.make("/moved") },
|
||||
projectID: Project.ID.global,
|
||||
})
|
||||
yield* Effect.yieldNow
|
||||
yield* exa.dispose
|
||||
const parallel = yield* register("parallel")
|
||||
expect((yield* websearch.query({ query: "new affinity" }, { sessionID: firstSession })).providerID).toBe(
|
||||
parallel.providerID,
|
||||
)
|
||||
yield* parallel.dispose
|
||||
const tavily = yield* register("tavily")
|
||||
exa.failure.cause = TestWebSearch.httpError()
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
expect((yield* Fiber.join(pending)).providerID).toBe(tavily.providerID)
|
||||
yield* register("parallel")
|
||||
expect((yield* websearch.query({ query: "still new affinity" }, { sessionID: firstSession })).providerID).toBe(
|
||||
parallel.providerID,
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("fails when web search is explicitly disabled", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* register("exa")
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ApplicationLifecycle } from "../lifecycle"
|
||||
import { finishFirstLaunchOnboarding, isFirstLaunchOnboardingPending } from "../lifecycle/onboarding"
|
||||
import { BackgroundService } from "../service/background-service"
|
||||
import { DesktopCli } from "../service/desktop-cli"
|
||||
import { SidecarCredentials } from "../service/sidecar-credentials"
|
||||
import { getDefaultServerUrl, setDefaultServerUrl } from "../service/server-settings"
|
||||
import { Updater } from "../updater"
|
||||
import { getLastFocusedWindow, setBackgroundColor } from "../windows"
|
||||
@@ -29,8 +30,8 @@ export const appHandlers = AppRpcs.toLayer(
|
||||
const logging = yield* DesktopLogging.Service
|
||||
const runFork = Effect.runForkWith(yield* Effect.context())
|
||||
return AppRpcs.of({
|
||||
AppAwaitInitialization: () => background.connection,
|
||||
AppReconnectService: () => background.reconnect,
|
||||
AppAwaitInitialization: () => background.connection.pipe(Effect.map(SidecarCredentials.ready)),
|
||||
AppReconnectService: () => background.reconnect.pipe(Effect.map(SidecarCredentials.ready)),
|
||||
AppConsumeInitialDeepLinks: () => Effect.sync(lifecycle.consumeInitialDeepLinks),
|
||||
AppGetDefaultServerUrl: () => Effect.sync(getDefaultServerUrl),
|
||||
AppSetDefaultServerUrl: ({ url }) => Effect.sync(() => setDefaultServerUrl(url)),
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
export * as BackgroundServiceState from "./background-service-state"
|
||||
|
||||
import { Effect, Exit, Ref } from "effect"
|
||||
import type { ServerReadyData } from "../../shared/ipc-contract"
|
||||
import type { SidecarCredentials } from "./sidecar-credentials"
|
||||
|
||||
export const make = Effect.fn("BackgroundServiceState.make")(function* (options: {
|
||||
readonly initial: Effect.Effect<ServerReadyData, unknown>
|
||||
readonly reconnect: Effect.Effect<ServerReadyData>
|
||||
readonly initial: Effect.Effect<SidecarCredentials.Data, unknown>
|
||||
readonly reconnect: Effect.Effect<SidecarCredentials.Data>
|
||||
}) {
|
||||
// Every Exit is an Effect, so the latest resolution replays directly for each consumer.
|
||||
const current = yield* Ref.make<Exit.Exit<ServerReadyData, unknown>>(yield* options.initial.pipe(Effect.exit))
|
||||
const current = yield* Ref.make<Exit.Exit<SidecarCredentials.Data, unknown>>(yield* options.initial.pipe(Effect.exit))
|
||||
return {
|
||||
connection: Ref.get(current).pipe(Effect.flatten, Effect.orDie),
|
||||
reconnect: options.reconnect.pipe(Effect.tap((next) => Ref.set(current, Exit.succeed(next)))),
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { app } from "electron"
|
||||
import { Context, Effect, FileSystem, Layer, Path } from "effect"
|
||||
import type { ServerReadyData } from "../../shared/ipc-contract"
|
||||
import { BackgroundServiceState } from "./background-service-state"
|
||||
import { cleanStages, DesktopCli } from "./desktop-cli"
|
||||
import { SidecarCredentials } from "./sidecar-credentials"
|
||||
|
||||
export * as BackgroundService from "./background-service"
|
||||
|
||||
export interface Interface {
|
||||
readonly connection: Effect.Effect<ServerReadyData>
|
||||
readonly reconnect: Effect.Effect<ServerReadyData>
|
||||
readonly connection: Effect.Effect<SidecarCredentials.Data>
|
||||
readonly reconnect: Effect.Effect<SidecarCredentials.Data>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("opencode/desktop/BackgroundService") {}
|
||||
@@ -56,10 +56,9 @@ const connect = Effect.fn("BackgroundService.connect")(function* (mode: "initial
|
||||
...endpoint(url.origin),
|
||||
})
|
||||
if (mode === "initial" && isolated && cli.binary) yield* cleanStages(cli.binary).pipe(Effect.orDie)
|
||||
return {
|
||||
url: url.origin,
|
||||
password: service.auth.password,
|
||||
} satisfies ServerReadyData
|
||||
const ready = { url: url.origin, password: service.auth.password } satisfies SidecarCredentials.Data
|
||||
SidecarCredentials.set(ready)
|
||||
return ready
|
||||
})
|
||||
|
||||
function endpoint(url: string | undefined) {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { authorization, ready } from "./sidecar-credentials"
|
||||
|
||||
const sidecar = { url: "http://127.0.0.1:4096", password: "secret" }
|
||||
const expected = `Basic ${Buffer.from("opencode:secret").toString("base64")}`
|
||||
|
||||
describe("sidecar authorization", () => {
|
||||
test("adds the Basic credential only for the sidecar origin", () => {
|
||||
expect(authorization(sidecar, "http://127.0.0.1:4096/api/session?limit=1")).toBe(expected)
|
||||
expect(authorization(sidecar, "http://127.0.0.1:4097/api/session")).toBeUndefined()
|
||||
expect(authorization(sidecar, "http://localhost:4096/api/session")).toBeUndefined()
|
||||
expect(authorization(sidecar, "https://127.0.0.1:4096/api/session")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("hands the renderer the origin only", () => {
|
||||
expect(ready(sidecar)).toEqual({ url: sidecar.url })
|
||||
})
|
||||
|
||||
test("adds nothing before the sidecar is known or when it has no password", () => {
|
||||
expect(authorization(undefined, "http://127.0.0.1:4096/api/session")).toBeUndefined()
|
||||
expect(authorization({ url: sidecar.url, password: null }, "http://127.0.0.1:4096/api/session")).toBeUndefined()
|
||||
expect(authorization(sidecar, "not a url")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
export * as SidecarCredentials from "./sidecar-credentials"
|
||||
|
||||
import type { ServerReadyData } from "../../shared/ipc-contract"
|
||||
|
||||
export type Data = ServerReadyData & { password: string | null }
|
||||
|
||||
// The renderer talks to the sidecar without an Authorization header; the main process adds it from
|
||||
// here so GET requests stay CORS-simple and skip the preflight round trip. Both the initial connection
|
||||
// and every reconnect publish the current endpoint.
|
||||
let current: Data | undefined
|
||||
|
||||
export function set(data: Data) {
|
||||
current = data
|
||||
}
|
||||
|
||||
export function get() {
|
||||
return current
|
||||
}
|
||||
|
||||
/** What the renderer learns about the sidecar: its origin, never its credential. */
|
||||
export function ready(data: Data): ServerReadyData {
|
||||
return { url: data.url }
|
||||
}
|
||||
|
||||
/** The Basic credential for a request to the sidecar origin, or undefined for any other URL. */
|
||||
export function authorization(sidecar: Data | undefined, url: string) {
|
||||
if (!sidecar?.password || !URL.canParse(url)) return
|
||||
if (new URL(url).origin !== sidecar.url) return
|
||||
return `Basic ${Buffer.from(`opencode:${sidecar.password}`).toString("base64")}`
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { BrowserWindow } from "electron"
|
||||
import { addRendererHeaders } from "./headers"
|
||||
import { SidecarCredentials } from "../service/sidecar-credentials"
|
||||
import { addRendererHeaders, hasHeader, upsertHeader } from "./headers"
|
||||
import { isRendererUrl } from "./protocol"
|
||||
|
||||
const rendererPermissions = new Set(["clipboard-sanitized-write", "notifications"])
|
||||
@@ -31,6 +32,25 @@ export function wireNavigationPolicy(win: BrowserWindow, openExternalURL: (url:
|
||||
}
|
||||
|
||||
export function wireRendererHeaders(win: BrowserWindow) {
|
||||
// The renderer sends sidecar requests without credentials, so its GETs are CORS-simple and need no
|
||||
// preflight. Electron applies these listeners in Chromium's extraHeaders mode, after the CORS
|
||||
// decision, so adding Authorization here does not reintroduce one.
|
||||
//
|
||||
// Only the renderer's own top-level frame is credentialed. Other content in this session (web views,
|
||||
// embedded pages) can reach the same loopback origin and must not inherit its access. Requests with
|
||||
// no frame, such as from a service worker, are not credentialed either; the renderer registers none.
|
||||
win.webContents.session.webRequest.onBeforeSendHeaders(
|
||||
{ urls: ["http://127.0.0.1/*", "http://localhost/*"] },
|
||||
(details, callback) => {
|
||||
const frame = details.frame
|
||||
const renderer = !!frame && frame.parent === null && isRendererUrl(frame.url)
|
||||
const authorization = renderer && SidecarCredentials.authorization(SidecarCredentials.get(), details.url)
|
||||
if (authorization && !hasHeader(details.requestHeaders, "Authorization")) {
|
||||
upsertHeader(details.requestHeaders, "Authorization", authorization)
|
||||
}
|
||||
callback({ requestHeaders: details.requestHeaders })
|
||||
},
|
||||
)
|
||||
win.webContents.session.webRequest.onHeadersReceived((details, callback) => {
|
||||
const responseHeaders = details.responseHeaders ?? {}
|
||||
addRendererHeaders(responseHeaders, { document: isRendererUrl(details.url, true) })
|
||||
|
||||
@@ -49,12 +49,8 @@ export function MigrationStatus(props: { server: ServerReadyData }) {
|
||||
await wait(1_000, abort.signal)
|
||||
if (abort.signal.aborted) return
|
||||
|
||||
const client = OpenCode.make({
|
||||
baseUrl: props.server.url,
|
||||
headers: props.server.password
|
||||
? { Authorization: `Basic ${btoa(`opencode:${props.server.password}`)}` }
|
||||
: undefined,
|
||||
})
|
||||
// The main process credentials sidecar requests; see `wireRendererHeaders`.
|
||||
const client = OpenCode.make({ baseUrl: props.server.url })
|
||||
|
||||
void (async () => {
|
||||
while (true) {
|
||||
|
||||
@@ -27,7 +27,7 @@ describe("desktop renderer initialization", () => {
|
||||
})
|
||||
|
||||
test("returns initialized sidecar data", () => {
|
||||
const sidecar = { url: "http://127.0.0.1:1234", password: "secret" }
|
||||
const sidecar = { url: "http://127.0.0.1:1234" }
|
||||
|
||||
expect(initializationData(Object.assign(() => sidecar, { error: undefined }))).toBe(sidecar)
|
||||
})
|
||||
@@ -47,7 +47,7 @@ describe("desktop renderer initialization", () => {
|
||||
})
|
||||
|
||||
test("refreshes the managed sidecar endpoint", async () => {
|
||||
const sidecar = { url: "http://127.0.0.1:4321", password: "next" }
|
||||
const sidecar = { url: "http://127.0.0.1:4321" }
|
||||
const updates: (typeof sidecar)[] = []
|
||||
const resolve = createSidecarResolver({
|
||||
api: { reconnectService: async () => sidecar },
|
||||
@@ -60,7 +60,7 @@ describe("desktop renderer initialization", () => {
|
||||
})
|
||||
|
||||
test("keeps the current sidecar when reconnection resolves the same endpoint", async () => {
|
||||
const sidecar = { url: "http://127.0.0.1:4321", password: "same" }
|
||||
const sidecar = { url: "http://127.0.0.1:4321" }
|
||||
const updates: (typeof sidecar)[] = []
|
||||
const resolve = createSidecarResolver({
|
||||
api: { reconnectService: async () => ({ ...sidecar }) },
|
||||
@@ -73,7 +73,7 @@ describe("desktop renderer initialization", () => {
|
||||
})
|
||||
|
||||
test("does not publish a sidecar resolved after cancellation", async () => {
|
||||
const sidecar = { url: "http://127.0.0.1:4321", password: "next" }
|
||||
const sidecar = { url: "http://127.0.0.1:4321" }
|
||||
const pending = Promise.withResolvers<typeof sidecar>()
|
||||
const updates: (typeof sidecar)[] = []
|
||||
const resolve = createSidecarResolver({
|
||||
|
||||
@@ -7,11 +7,10 @@ export function initializationData<A>(state: (() => A | undefined) & { error: un
|
||||
return state()
|
||||
}
|
||||
|
||||
// The main process adds Authorization to sidecar requests (`wireRendererHeaders`); the renderer never
|
||||
// holds the password, and its GETs carry only CORS-safelisted headers so they skip the preflight.
|
||||
export function sidecarHttp(data: SidecarData) {
|
||||
return {
|
||||
url: data.url,
|
||||
password: data.password ?? undefined,
|
||||
}
|
||||
return { url: data.url }
|
||||
}
|
||||
|
||||
export function createSidecarResolver(input: {
|
||||
@@ -29,7 +28,7 @@ export function createSidecarResolver(input: {
|
||||
}
|
||||
|
||||
function sameSidecar(current: SidecarData | undefined, next: SidecarData) {
|
||||
return current?.url === next.url && current.password === next.password
|
||||
return current?.url === next.url
|
||||
}
|
||||
|
||||
function markLocalServerStartup(error: unknown) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The sidecar password never crosses into the renderer; the main process adds it to sidecar requests.
|
||||
export type ServerReadyData = {
|
||||
url: string
|
||||
password: string | null
|
||||
}
|
||||
|
||||
export type TitlebarTheme = {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Rpc, RpcGroup } from "effect/unstable/rpc"
|
||||
|
||||
const ServerReadyData = Schema.Struct({
|
||||
url: Schema.String,
|
||||
password: Schema.NullOr(Schema.String),
|
||||
})
|
||||
|
||||
export const AppAwaitInitialization = Rpc.make("AppAwaitInitialization", { success: ServerReadyData })
|
||||
|
||||
@@ -18,6 +18,12 @@ export interface ToolEditor {
|
||||
}
|
||||
|
||||
export interface ToolHooks {
|
||||
readonly snapshot: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
/** Effective tool names advertised to this request. Remove names to hide tools; added names are ignored. */
|
||||
tools: string[]
|
||||
}
|
||||
readonly "execute.before": {
|
||||
tool: string
|
||||
readonly sessionID: Session.ID
|
||||
@@ -47,6 +53,7 @@ export interface ToolHooks {
|
||||
|
||||
// Only execute.before may fail: a Tool.Error rejects the call before the tool runs.
|
||||
export interface ToolFailures extends Record<keyof ToolHooks, unknown> {
|
||||
readonly snapshot: never
|
||||
readonly "execute.before": Tool.Error
|
||||
readonly "execute.after": never
|
||||
}
|
||||
|
||||
@@ -35,6 +35,12 @@ export interface ToolEditor {
|
||||
}
|
||||
|
||||
interface ToolHooks {
|
||||
readonly snapshot: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
/** Effective tool names advertised to this request. Remove names to hide tools; added names are ignored. */
|
||||
tools: string[]
|
||||
}
|
||||
readonly "execute.before": {
|
||||
tool: string
|
||||
readonly sessionID: Session.ID
|
||||
|
||||
@@ -7407,7 +7407,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Update project display metadata and workspace commands.",
|
||||
"description": "Update the project canonical directory, display metadata, and workspace commands.",
|
||||
"summary": "Update project",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
@@ -7415,6 +7415,9 @@
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"canonical": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -14589,7 +14592,8 @@
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": ["random"]
|
||||
"enum": ["random"],
|
||||
"description": "Reuse a randomly selected provider until it is rate limited, then switch to another available provider."
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
|
||||
@@ -29,7 +29,7 @@ export const ProjectGroup = HttpApiGroup.make("server.project")
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.project.update",
|
||||
summary: "Update project",
|
||||
description: "Update project display metadata and workspace commands.",
|
||||
description: "Update the project canonical directory, display metadata, and workspace commands.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -4,7 +4,13 @@ import { Schema } from "effect"
|
||||
import { WebSearch } from "../websearch.js"
|
||||
|
||||
export class Info extends Schema.Class<Info>("ConfigWebSearch.Info")({
|
||||
provider: Schema.Union([Schema.Literal("random"), WebSearch.ID]),
|
||||
provider: Schema.Union([
|
||||
Schema.Literal("random").annotate({
|
||||
description:
|
||||
"Reuse a randomly selected provider until it is rate limited, then switch to another available provider.",
|
||||
}),
|
||||
WebSearch.ID,
|
||||
]),
|
||||
}) {}
|
||||
|
||||
export const Selection = Schema.Union([Schema.Literal(false), Info])
|
||||
|
||||
@@ -50,6 +50,7 @@ export interface Info extends Schema.Schema.Type<typeof Info> {}
|
||||
|
||||
export const UpdateInput = Schema.Struct({
|
||||
projectID: ID,
|
||||
canonical: optional(AbsolutePath),
|
||||
name: optional(Schema.String),
|
||||
icon: optional(Icon),
|
||||
commands: optional(Commands),
|
||||
|
||||
@@ -233,7 +233,7 @@ export namespace Execution {
|
||||
export const Interrupted = Event.durable({
|
||||
type: "session.execution.interrupted",
|
||||
...options,
|
||||
schema: { ...Base, reason: Schema.Literals(["user", "shutdown", "superseded"]) },
|
||||
schema: { ...Base, reason: Schema.Literals(["user", "shutdown", "superseded", "inactivity"]) },
|
||||
})
|
||||
export type Interrupted = typeof Interrupted.Type
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Spinner } from "./spinner"
|
||||
export function DialogUpdate(props: {
|
||||
check?: (signal: AbortSignal) => Promise<string | undefined>
|
||||
state: () => UpdateState | undefined
|
||||
skip: () => void
|
||||
install: () => Promise<void>
|
||||
restart: () => void
|
||||
}) {
|
||||
@@ -47,7 +48,16 @@ export function DialogUpdate(props: {
|
||||
: type === "installed"
|
||||
? { label: "Restart", run: props.restart }
|
||||
: undefined
|
||||
return [{ label: "Skip", run: () => dialog.clear() }, ...(confirm ? [confirm] : [])]
|
||||
return [
|
||||
{
|
||||
label: "Skip",
|
||||
run: () => {
|
||||
props.skip()
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
...(confirm ? [confirm] : []),
|
||||
]
|
||||
})
|
||||
|
||||
createEffect(() => setActive(Math.max(0, buttons().length - 1)))
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import {
|
||||
CliRenderer,
|
||||
RGBA,
|
||||
RootTextNodeRenderable,
|
||||
TextNodeRenderable,
|
||||
type Renderable,
|
||||
type RenderContext,
|
||||
type TextNodeOptions,
|
||||
} from "@opentui/core"
|
||||
import { extend } from "@opentui/solid"
|
||||
import { createEffect, onCleanup } from "solid-js"
|
||||
import { smootherstep } from "./tab-pulse"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
import { webSearchProviderName } from "../util/tool-display"
|
||||
|
||||
type Value = { id: string; provider: string; running: boolean }
|
||||
type Options = TextNodeOptions & { value?: Value; enabled?: boolean }
|
||||
|
||||
const FADE = 80
|
||||
const RESIZE = 60
|
||||
const DURATION = FADE * 2 + RESIZE
|
||||
|
||||
/** An inline span: keep native wrapping, selection, and the surrounding tool's styles. */
|
||||
export class RetryProviderRenderable extends TextNodeRenderable {
|
||||
private readonly ctx: CliRenderer
|
||||
private current?: Value
|
||||
private enabledValue: boolean
|
||||
private painted = false
|
||||
private disposed = false
|
||||
private elapsed: number | undefined
|
||||
private fresh = false
|
||||
private source = ""
|
||||
private target = ""
|
||||
private displayed = ""
|
||||
private opacity = 1
|
||||
private fromOpacity = 1
|
||||
|
||||
constructor(ctx: RenderContext, options: Options) {
|
||||
super(options)
|
||||
if (!(ctx instanceof CliRenderer)) throw new Error("RetryProvider requires a renderer frame clock")
|
||||
this.ctx = ctx
|
||||
this.enabledValue = options.enabled ?? true
|
||||
if (options.value) this.value = options.value
|
||||
}
|
||||
|
||||
private markPainted = () => {
|
||||
if (!this.onScreen()) return
|
||||
this.painted = true
|
||||
this.ctx.off("frame", this.markPainted)
|
||||
}
|
||||
|
||||
private onScreen() {
|
||||
let node: TextNodeRenderable | null = this
|
||||
while (node && !(node instanceof RootTextNodeRenderable)) {
|
||||
if (!node.visible) return false
|
||||
node = node.parent
|
||||
}
|
||||
if (!node) return false
|
||||
const text = node.textParent
|
||||
if (
|
||||
text.width <= 0 ||
|
||||
text.height <= 0 ||
|
||||
text.screenX < 0 ||
|
||||
text.screenY < 0 ||
|
||||
text.screenX + text.width > this.ctx.width ||
|
||||
text.screenY + text.height > this.ctx.height
|
||||
)
|
||||
return false
|
||||
// Be conservative with clipped rows: do not start a transition as history enters the viewport.
|
||||
for (let parent: Renderable | null = text; parent; parent = parent.parent) {
|
||||
if (!parent.visible) return false
|
||||
if (parent.overflow === "visible") continue
|
||||
if (
|
||||
text.screenX < parent.screenX ||
|
||||
text.screenY < parent.screenY ||
|
||||
text.screenX + text.width > parent.screenX + parent.width ||
|
||||
text.screenY + text.height > parent.screenY + parent.height
|
||||
)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
set value(value: Value) {
|
||||
if (this.disposed) return
|
||||
const previous = this.current
|
||||
this.current = value
|
||||
if (!value.running) this.ctx.off("frame", this.markPainted)
|
||||
if (previous?.id === value.id && previous.provider === value.provider) return
|
||||
this.target = webSearchProviderName(value.provider)
|
||||
const retry =
|
||||
this.enabledValue &&
|
||||
this.painted &&
|
||||
previous?.id === value.id &&
|
||||
previous.running &&
|
||||
value.running &&
|
||||
this.onScreen()
|
||||
if (!retry) {
|
||||
this.finish()
|
||||
this.painted = false
|
||||
this.ctx.off("frame", this.markPainted)
|
||||
if (value.running) this.ctx.on("frame", this.markPainted)
|
||||
return
|
||||
}
|
||||
// During fade-out, replace the destination without restarting or queuing transitions.
|
||||
if (this.elapsed !== undefined && this.elapsed < FADE) return
|
||||
this.source = this.displayed
|
||||
this.fromOpacity = this.opacity
|
||||
const start = this.elapsed === undefined
|
||||
this.elapsed = 0
|
||||
this.fresh = true
|
||||
if (start) {
|
||||
this.ctx.setFrameCallback(this.tick)
|
||||
this.ctx.requestLive()
|
||||
}
|
||||
}
|
||||
|
||||
set enabled(value: boolean) {
|
||||
if (value === this.enabledValue) return
|
||||
this.enabledValue = value
|
||||
if (!value) this.finish()
|
||||
}
|
||||
|
||||
private tick = async (delta: number) => {
|
||||
if (this.elapsed === undefined) return
|
||||
if (!this.onScreen()) {
|
||||
this.finish()
|
||||
this.painted = false
|
||||
if (this.current?.running) this.ctx.on("frame", this.markPainted)
|
||||
return
|
||||
}
|
||||
// Ignore time the renderer spent idle before this transition started.
|
||||
this.elapsed += this.fresh ? 0 : delta
|
||||
this.fresh = false
|
||||
if (this.elapsed >= DURATION) return this.finish()
|
||||
if (this.elapsed < FADE) {
|
||||
return this.show(this.source, this.fromOpacity * (1 - smootherstep(this.elapsed / FADE)))
|
||||
}
|
||||
if (this.elapsed < FADE + RESIZE) {
|
||||
// Move the query only while the name is invisible; never reveal partial provider glyphs.
|
||||
const from = stringWidth(this.source)
|
||||
const to = stringWidth(this.target)
|
||||
return this.show(" ".repeat(Math.round(from + (to - from) * smootherstep((this.elapsed - FADE) / RESIZE))), 0)
|
||||
}
|
||||
this.show(this.target, smootherstep((this.elapsed - FADE - RESIZE) / FADE))
|
||||
}
|
||||
|
||||
private show(text: string, opacity: number) {
|
||||
if (text === this.displayed && opacity === this.opacity) return
|
||||
this.displayed = text
|
||||
this.opacity = opacity
|
||||
this.children = [text]
|
||||
}
|
||||
|
||||
private stop() {
|
||||
if (this.elapsed !== undefined) {
|
||||
this.ctx.removeFrameCallback(this.tick)
|
||||
this.ctx.dropLive()
|
||||
}
|
||||
this.elapsed = undefined
|
||||
}
|
||||
|
||||
private finish() {
|
||||
this.stop()
|
||||
this.show(this.target, 1)
|
||||
}
|
||||
|
||||
override gatherWithInheritedStyle(style?: Parameters<TextNodeRenderable["gatherWithInheritedStyle"]>[0]) {
|
||||
const chunks = super.gatherWithInheritedStyle(style)
|
||||
if (this.opacity === 1) return chunks
|
||||
return chunks.map((chunk) => {
|
||||
const fg = RGBA.clone(chunk.fg ?? RGBA.defaultForeground())
|
||||
fg.a *= this.opacity
|
||||
return { ...chunk, fg }
|
||||
})
|
||||
}
|
||||
|
||||
override destroy() {
|
||||
if (this.disposed) return
|
||||
this.disposed = true
|
||||
this.ctx.off("frame", this.markPainted)
|
||||
this.stop()
|
||||
super.destroy()
|
||||
}
|
||||
|
||||
override destroyRecursively() {
|
||||
this.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
extend({ retry_provider: RetryProviderRenderable })
|
||||
|
||||
export function RetryProvider(props: { value: Value; enabled: boolean }) {
|
||||
// Solid's text-node reconciler only applies inline styles; control the custom span through its ref.
|
||||
return (
|
||||
<retry_provider
|
||||
ref={(node) => {
|
||||
onCleanup(() => node.destroy())
|
||||
createEffect(() => {
|
||||
node.enabled = props.enabled
|
||||
node.value = props.value
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
declare module "@opentui/solid" {
|
||||
interface OpenTUIComponents {
|
||||
retry_provider: typeof RetryProviderRenderable
|
||||
}
|
||||
}
|
||||
@@ -95,14 +95,12 @@ export const { use: useUpdateNotification, provider: UpdateNotificationProvider
|
||||
// The notification can predate an installation through /update.
|
||||
if (known && active?.type !== "installing" && !(active?.type === "installed" && active.version === known.version))
|
||||
setState({ type: known.type, version: known.version })
|
||||
// Manual checks hide the current notice without marking the version as seen.
|
||||
if (origin === "manual") setNotification(undefined)
|
||||
if (origin === "notification") dismiss()
|
||||
const status = state()?.type
|
||||
dialog.replace(() => (
|
||||
<DialogUpdate
|
||||
check={status === undefined || status === "failed" ? check : undefined}
|
||||
state={state}
|
||||
skip={dismiss}
|
||||
install={install}
|
||||
restart={restart}
|
||||
/>
|
||||
|
||||
@@ -47,8 +47,8 @@ import {
|
||||
primitiveInputSummary,
|
||||
toolDisplayContent,
|
||||
toolDisplayMetadata,
|
||||
webSearchProviderLabel,
|
||||
} from "../../util/tool-display"
|
||||
import { RetryProvider } from "../../component/retry-provider"
|
||||
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import { useClient } from "../../context/client"
|
||||
import { useEditorContext } from "../../context/editor"
|
||||
@@ -3374,9 +3374,26 @@ function WebFetch(props: ToolProps) {
|
||||
}
|
||||
|
||||
function WebSearch(props: ToolProps) {
|
||||
const ctx = use()
|
||||
const provider = createMemo(() => stringValue(props.metadata.provider))
|
||||
return (
|
||||
<InlineTool icon="◈" pending="Searching web…" complete={stringValue(props.input.query)} part={props.part}>
|
||||
{webSearchProviderLabel(props.metadata.provider)} "{stringValue(props.input.query)}"
|
||||
<Show when={provider()} fallback="Web Search">
|
||||
{(value) => (
|
||||
<>
|
||||
Web Search via{" "}
|
||||
<RetryProvider
|
||||
value={{
|
||||
id: `${ctx.sessionID}:${props.part.time.created}:${props.part.id}`,
|
||||
provider: value(),
|
||||
running: props.part.state.status === "running",
|
||||
}}
|
||||
enabled={ctx.config.animations ?? true}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Show>{" "}
|
||||
"{stringValue(props.input.query)}"
|
||||
</InlineTool>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,9 +19,14 @@ export function primitiveInputSummary(input: Record<string, unknown>, omit: read
|
||||
return `[${entries.map(([key, value]) => `${key}=${String(value)}`).join(", ")}]`
|
||||
}
|
||||
|
||||
export function webSearchProviderName(provider: unknown) {
|
||||
if (typeof provider !== "string" || !provider) return ""
|
||||
return `${provider[0].toUpperCase()}${provider.slice(1)}`
|
||||
}
|
||||
|
||||
export function webSearchProviderLabel(provider: unknown) {
|
||||
if (typeof provider !== "string" || !provider) return "Web Search"
|
||||
return `Web Search via ${provider[0].toUpperCase()}${provider.slice(1)}`
|
||||
const name = webSearchProviderName(provider)
|
||||
return name ? `Web Search via ${name}` : "Web Search"
|
||||
}
|
||||
|
||||
export function toolDisplayMetadata(state: unknown): Record<string, unknown> {
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { BoxRenderable, RGBA, TextAttributes, TextRenderable } from "@opentui/core"
|
||||
import { createTestRenderer, ManualClock } from "@opentui/core/testing"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { createSignal, Show } from "solid-js"
|
||||
import { RetryProvider, RetryProviderRenderable } from "../../src/component/retry-provider"
|
||||
|
||||
async function fixture() {
|
||||
const clock = new ManualClock()
|
||||
const app = await createTestRenderer({ width: 60, height: 2, useThread: false, clock })
|
||||
app.renderer.pause()
|
||||
const text = new TextRenderable(app.renderer, { fg: "#eeeeee", bg: "#111111", attributes: TextAttributes.BOLD })
|
||||
const provider = new RetryProviderRenderable(app.renderer, {
|
||||
value: { id: "call-1", provider: "exa", running: true },
|
||||
})
|
||||
text.add("Web Search via ")
|
||||
text.add(provider)
|
||||
text.add(' "query"')
|
||||
app.renderer.root.add(text)
|
||||
const renderOnce = async () => {
|
||||
await app.waitFor(() => !app.renderer.getSchedulerState().isRendering)
|
||||
await app.renderOnce()
|
||||
}
|
||||
return {
|
||||
...app,
|
||||
clock,
|
||||
text,
|
||||
provider,
|
||||
renderOnce,
|
||||
step: async (millis: number) => {
|
||||
clock.setTime(clock.now() + millis)
|
||||
await renderOnce()
|
||||
},
|
||||
[Symbol.dispose]: () => {
|
||||
provider.destroy()
|
||||
app.renderer.destroy()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test("only an already-painted running call starts a provider transition", async () => {
|
||||
using app = await fixture()
|
||||
// Updates before the first paint are not a visible retry.
|
||||
app.provider.value = { id: "call-1", provider: "parallel", running: true }
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame().trim()).toBe('Web Search via Parallel "query"')
|
||||
expect(app.renderer.liveRequestCount).toBe(0)
|
||||
app.provider.value = { id: "call-1", provider: "parallel", running: true }
|
||||
app.provider.value = { id: "call-1", provider: "parallel", running: false }
|
||||
app.provider.value = { id: "call-1", provider: "tavily", running: false }
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame().trim()).toBe('Web Search via Tavily "query"')
|
||||
expect(app.renderer.liveRequestCount).toBe(0)
|
||||
// A new invocation/history view must not animate from the previous call's label.
|
||||
app.provider.value = { id: "call-2", provider: "exa", running: true }
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame().trim()).toBe('Web Search via Exa "query"')
|
||||
expect(app.renderer.liveRequestCount).toBe(0)
|
||||
})
|
||||
|
||||
test("fades only the provider, changes width while invisible, and settles without a timer", async () => {
|
||||
using app = await fixture()
|
||||
await app.renderOnce()
|
||||
const initial = app.captureSpans()
|
||||
app.clock.setTime(2000)
|
||||
app.provider.value = { id: "call-1", provider: "parallel", running: true }
|
||||
await app.renderOnce()
|
||||
expect(app.captureSpans()).toEqual(initial)
|
||||
expect(app.renderer.liveRequestCount).toBe(1)
|
||||
await app.step(40)
|
||||
const middle = app.captureSpans().lines[0].spans
|
||||
expect(middle.find((span) => span.text.includes("Exa"))?.fg.toInts()).not.toEqual(
|
||||
initial.lines[0].spans[0].fg.toInts(),
|
||||
)
|
||||
expect(middle.find((span) => span.text.includes("Web Search"))?.fg.toInts()).toEqual(
|
||||
initial.lines[0].spans[0].fg.toInts(),
|
||||
)
|
||||
expect(middle.find((span) => span.text.includes("query"))?.fg.toInts()).toEqual(initial.lines[0].spans[0].fg.toInts())
|
||||
expect(
|
||||
middle.filter((span) => span.text.trim()).every((span) => Boolean(span.attributes & TextAttributes.BOLD)),
|
||||
).toBe(true)
|
||||
await app.step(40)
|
||||
const before = app.captureCharFrame().indexOf('"query"')
|
||||
await app.step(30)
|
||||
const moving = app.captureCharFrame().indexOf('"query"')
|
||||
expect(moving).toBeGreaterThan(before)
|
||||
expect(moving).toBeLessThan("Web Search via Parallel ".length)
|
||||
await app.step(30)
|
||||
await app.step(80)
|
||||
expect(app.captureCharFrame().trim()).toBe('Web Search via Parallel "query"')
|
||||
expect(app.captureSpans().lines[0].spans[0].fg.toInts()).toEqual(initial.lines[0].spans[0].fg.toInts())
|
||||
expect(app.renderer.liveRequestCount).toBe(0)
|
||||
})
|
||||
|
||||
test("coalesces rapid fallbacks and does not restart on completion", async () => {
|
||||
using app = await fixture()
|
||||
await app.renderOnce()
|
||||
app.provider.value = { id: "call-1", provider: "parallel", running: true }
|
||||
await app.renderOnce()
|
||||
await app.step(40)
|
||||
app.provider.value = { id: "call-1", provider: "firecrawl", running: true }
|
||||
app.provider.value = { id: "call-1", provider: "firecrawl", running: false }
|
||||
await app.step(180)
|
||||
expect(app.captureCharFrame().trim()).toBe('Web Search via Firecrawl "query"')
|
||||
expect(app.renderer.liveRequestCount).toBe(0)
|
||||
})
|
||||
|
||||
test("retargets a fading-in provider without flashing back to full brightness", async () => {
|
||||
using app = await fixture()
|
||||
await app.renderOnce()
|
||||
app.provider.value = { id: "call-1", provider: "parallel", running: true }
|
||||
await app.renderOnce()
|
||||
await app.step(180)
|
||||
const middle = app.captureSpans()
|
||||
app.provider.value = { id: "call-1", provider: "tavily", running: true }
|
||||
await app.renderOnce()
|
||||
expect(app.captureSpans()).toEqual(middle)
|
||||
await app.step(220)
|
||||
expect(app.captureCharFrame().trim()).toBe('Web Search via Tavily "query"')
|
||||
expect(app.renderer.liveRequestCount).toBe(0)
|
||||
})
|
||||
|
||||
test("disabling animations settles immediately and recursive destruction releases active work", async () => {
|
||||
using app = await fixture()
|
||||
await app.renderOnce()
|
||||
app.provider.value = { id: "call-1", provider: "parallel", running: true }
|
||||
await app.renderOnce()
|
||||
app.provider.enabled = false
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame().trim()).toBe('Web Search via Parallel "query"')
|
||||
expect(app.renderer.liveRequestCount).toBe(0)
|
||||
app.provider.value = { id: "call-1", provider: "exa", running: true }
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame().trim()).toBe('Web Search via Exa "query"')
|
||||
expect(app.renderer.liveRequestCount).toBe(0)
|
||||
app.provider.enabled = true
|
||||
app.provider.value = { id: "call-1", provider: "tavily", running: true }
|
||||
expect(app.renderer.liveRequestCount).toBe(1)
|
||||
app.text.remove(app.provider)
|
||||
app.provider.destroyRecursively()
|
||||
expect(app.renderer.liveRequestCount).toBe(0)
|
||||
})
|
||||
|
||||
test("a hidden first paint does not arm the transition", async () => {
|
||||
using app = await fixture()
|
||||
app.text.visible = false
|
||||
await app.renderOnce()
|
||||
app.provider.value = { id: "call-1", provider: "parallel", running: true }
|
||||
await app.renderOnce()
|
||||
expect(app.renderer.liveRequestCount).toBe(0)
|
||||
app.text.visible = true
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame().trim()).toBe('Web Search via Parallel "query"')
|
||||
expect(app.renderer.liveRequestCount).toBe(0)
|
||||
app.provider.value = { id: "call-1", provider: "tavily", running: true }
|
||||
expect(app.renderer.liveRequestCount).toBe(1)
|
||||
})
|
||||
|
||||
test("offscreen and ancestor-clipped rows show the latest provider on entry, without animation", async () => {
|
||||
using app = await fixture()
|
||||
app.text.top = 4
|
||||
await app.renderOnce()
|
||||
app.provider.value = { id: "call-1", provider: "parallel", running: true }
|
||||
await app.renderOnce()
|
||||
expect(app.renderer.liveRequestCount).toBe(0)
|
||||
app.text.top = 0
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame().trim()).toBe('Web Search via Parallel "query"')
|
||||
const clip = new BoxRenderable(app.renderer, { width: 50, height: 1, overflow: "hidden" })
|
||||
app.renderer.root.remove(app.text)
|
||||
clip.add(app.text)
|
||||
app.renderer.root.add(clip)
|
||||
app.text.top = 1
|
||||
await app.renderOnce()
|
||||
app.provider.value = { id: "call-1", provider: "tavily", running: true }
|
||||
await app.renderOnce()
|
||||
expect(app.renderer.liveRequestCount).toBe(0)
|
||||
app.text.top = 0
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame().trim()).toBe('Web Search via Tavily "query"')
|
||||
expect(app.renderer.liveRequestCount).toBe(0)
|
||||
})
|
||||
|
||||
test("a transition settles when hidden and waits for a new paint before animating again", async () => {
|
||||
using app = await fixture()
|
||||
await app.renderOnce()
|
||||
app.provider.value = { id: "call-1", provider: "parallel", running: true }
|
||||
await app.renderOnce()
|
||||
await app.step(40)
|
||||
app.text.visible = false
|
||||
await app.step(40)
|
||||
expect(app.renderer.liveRequestCount).toBe(0)
|
||||
expect(app.captureCharFrame().trim()).toBe("")
|
||||
app.text.visible = true
|
||||
app.provider.value = { id: "call-1", provider: "tavily", running: true }
|
||||
expect(app.renderer.liveRequestCount).toBe(0)
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame().trim()).toBe('Web Search via Tavily "query"')
|
||||
app.provider.value = { id: "call-1", provider: "exa", running: true }
|
||||
expect(app.renderer.liveRequestCount).toBe(1)
|
||||
app.text.top = 4
|
||||
await app.step(40)
|
||||
expect(app.renderer.liveRequestCount).toBe(0)
|
||||
app.text.top = 0
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame().trim()).toBe('Web Search via Exa "query"')
|
||||
expect(app.renderer.liveRequestCount).toBe(0)
|
||||
})
|
||||
|
||||
test("Solid unmount releases a running transition and its frame listener", async () => {
|
||||
const clock = new ManualClock()
|
||||
const [visible, setVisible] = createSignal(true)
|
||||
const [provider, setProvider] = createSignal("exa")
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<Show when={visible()}>
|
||||
<text fg="#eeeeee">
|
||||
Web Search via <RetryProvider value={{ id: "call", provider: provider(), running: true }} enabled={true} />
|
||||
</text>
|
||||
</Show>
|
||||
),
|
||||
{ width: 60, height: 2, useThread: false, clock },
|
||||
)
|
||||
try {
|
||||
app.renderer.pause()
|
||||
await app.renderOnce()
|
||||
setProvider("parallel")
|
||||
await app.waitFor(() => app.renderer.liveRequestCount === 1)
|
||||
setVisible(false)
|
||||
await app.waitFor(() => app.renderer.liveRequestCount === 0)
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame().trim()).toBe("")
|
||||
expect(app.renderer.listenerCount("frame")).toBe(0)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("the Solid inline span inherits colors and wraps like ordinary text after a Unicode retry", async () => {
|
||||
const clock = new ManualClock()
|
||||
const [provider, setProvider] = createSignal("firecrawl")
|
||||
const [running, setRunning] = createSignal(true)
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<box width={24}>
|
||||
<text fg="#222222" bg="#eeeeee">
|
||||
Web Search via{" "}
|
||||
<RetryProvider value={{ id: "call", provider: provider(), running: running() }} enabled={true} /> "a longer
|
||||
query"
|
||||
</text>
|
||||
<text fg="#222222" bg="#eeeeee">
|
||||
Web Search via 日本語 "a longer query"
|
||||
</text>
|
||||
</box>
|
||||
),
|
||||
{ width: 24, height: 6, useThread: false, clock },
|
||||
)
|
||||
try {
|
||||
app.renderer.pause()
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).toContain("Firecrawl")
|
||||
setProvider("日本語")
|
||||
await app.renderOnce()
|
||||
clock.setTime(220)
|
||||
await app.renderOnce()
|
||||
setRunning(false)
|
||||
await app.renderOnce()
|
||||
const lines = app
|
||||
.captureCharFrame()
|
||||
.trimEnd()
|
||||
.split("\n")
|
||||
.map((line) => line.trimEnd())
|
||||
expect(lines.slice(0, 2)).toEqual(lines.slice(2, 4))
|
||||
expect(app.captureCharFrame()).not.toContain("Firecrawl")
|
||||
expect(app.captureSpans().lines[0].spans[0].fg.equals(RGBA.fromHex("#222222"))).toBe(true)
|
||||
expect(app.renderer.liveRequestCount).toBe(0)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
@@ -14589,7 +14589,8 @@
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": ["random"]
|
||||
"enum": ["random"],
|
||||
"description": "Reuse a randomly selected provider until it is rate limited, then switch to another available provider."
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
|
||||
@@ -14589,7 +14589,8 @@
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": ["random"]
|
||||
"enum": ["random"],
|
||||
"description": "Reuse a randomly selected provider until it is rate limited, then switch to another available provider."
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
|
||||
@@ -1438,6 +1438,20 @@ interface ShellCreateBefore {
|
||||
|
||||
### Tools
|
||||
|
||||
Hide tools from one request. The hook runs when a model request captures its tool
|
||||
snapshot and receives the effective tool names permitted for that session and agent.
|
||||
Removing a name hides the tool from both the native tool list and the Code Mode catalog.
|
||||
|
||||
```ts
|
||||
await ctx.tool.hook("snapshot", (event) => {
|
||||
if (!browsers.has(event.sessionID)) event.tools = event.tools.filter((name) => !name.startsWith("browser_"))
|
||||
})
|
||||
```
|
||||
|
||||
- Names added by a hook are ignored; a hook cannot reveal a tool that permissions removed.
|
||||
- Registration stays location-wide. Use this hook when availability depends on the session, such as a connected client.
|
||||
- Hiding is catalog visibility, not authorization. A tool still enforces its own permission when it runs.
|
||||
|
||||
Inspect or replace tool input before execution.
|
||||
|
||||
```ts
|
||||
@@ -1459,10 +1473,17 @@ await ctx.tool.hook("execute.after", (event) => {
|
||||
|
||||
```ts
|
||||
interface ToolHooks {
|
||||
snapshot: ToolSnapshot
|
||||
"execute.before": ToolExecuteBefore
|
||||
"execute.after": ToolExecuteCompleted | ToolExecuteFailed
|
||||
}
|
||||
|
||||
interface ToolSnapshot {
|
||||
readonly sessionID: string
|
||||
readonly agent: string
|
||||
tools: string[]
|
||||
}
|
||||
|
||||
interface ToolHookContext {
|
||||
hook<Name extends keyof ToolHooks>(
|
||||
name: Name,
|
||||
@@ -1499,3 +1520,39 @@ Use versions compatible with the OpenCode release you target and test the
|
||||
installed package, not only a workspace-linked copy. Because the plugin API is
|
||||
beta, publish compatible plugin updates when V2 entrypoints or contracts
|
||||
change.
|
||||
|
||||
## Support V1
|
||||
|
||||
A plugin can support V1 and V2 from the same package entrypoint. Default export
|
||||
one object with a V1 `server()` function and a V2 `setup()` function:
|
||||
|
||||
```ts title="src/index.ts"
|
||||
import { Plugin } from "@opencode-ai/plugin"
|
||||
|
||||
export default {
|
||||
...Plugin.define({
|
||||
id: "example",
|
||||
async setup(ctx) {
|
||||
await ctx.tool.hook("execute.before", () => {
|
||||
console.log("A tool is about to run")
|
||||
})
|
||||
},
|
||||
}),
|
||||
async server() {
|
||||
return {
|
||||
"tool.execute.before": async () => {
|
||||
console.log("A tool is about to run")
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- V1 calls `server()` and uses the returned hooks.
|
||||
- V2 reads the default export's `id` and `setup()` (or `effect()` for Effect plugins), ignoring `server()`.
|
||||
- Keep each implementation on its own API; sharing an export does not translate V1 hooks into V2 hooks.
|
||||
- Spread `Plugin.define(...)` into the exported object so it type-checks the V2 definition separately from `server()`.
|
||||
|
||||
The V1 object form is supported in OpenCode `1.18.29`. Older V1 releases may
|
||||
expect function exports instead; test the installed package with the oldest V1
|
||||
release you intend to support and with V2.
|
||||
|
||||
@@ -298,6 +298,26 @@ Set the maximum number of lines and bytes retained from a tool result.
|
||||
}
|
||||
```
|
||||
|
||||
### Web search
|
||||
|
||||
Use `"random"` to randomly choose a search provider for each session and keep using it until it
|
||||
returns HTTP 429. OpenCode then retries the query with another available provider.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"websearch": {
|
||||
"provider": "random",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- Rate-limited providers cool down for `Retry-After`, or 60 seconds if it is missing or invalid.
|
||||
- When every provider is cooling down, the search fails without waiting.
|
||||
- Each session remembers its preferred provider; cooldowns are shared within a Location.
|
||||
- State is kept in memory. Moving a session or restarting its Location services resets its preference.
|
||||
- API and plugin queries without session context share a Location-level preference.
|
||||
- Set `provider` to a provider ID to disable automatic switching, or set `websearch` to `false` to disable search.
|
||||
|
||||
### MCP
|
||||
|
||||
Configure local and remote Model Context Protocol servers. Global timeouts can
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
---
|
||||
title: "Go"
|
||||
description: "Low cost subscription for open coding models."
|
||||
---
|
||||
|
||||
OpenCode Go is a low cost subscription — **$5 for your first month**, then **$10/month** — that gives you reliable access to popular open coding models.
|
||||
|
||||
Go works like any other provider in OpenCode. You subscribe to OpenCode Go and get your API key. It's **completely optional** and you don't need it to use OpenCode.
|
||||
|
||||
It is designed primarily for international users and provides stable global access.
|
||||
|
||||
## How it works
|
||||
|
||||
1. Sign in to the [OpenCode console](https://console.opencode.ai), subscribe to Go, add your billing details, and copy your API key.
|
||||
2. Run `/connect` in the TUI, select **OpenCode Go**, and paste your API key.
|
||||
|
||||
```text
|
||||
/connect
|
||||
```
|
||||
|
||||
3. Run `/models` to select a model available through Go.
|
||||
|
||||
```text
|
||||
/models
|
||||
```
|
||||
|
||||
<Callout>Only one member per workspace can subscribe to OpenCode Go.</Callout>
|
||||
|
||||
The current list of models includes:
|
||||
|
||||
- **Grok 4.5**
|
||||
- **GLM-5.2**
|
||||
- **GLM-5.1**
|
||||
- **GPT 5.6 Luna**
|
||||
- **Kimi K3**
|
||||
- **Kimi K2.7 Code**
|
||||
- **Kimi K2.6**
|
||||
- **MiMo-V2.5**
|
||||
- **MiMo-V2.5-Pro**
|
||||
- **MiniMax M3**
|
||||
- **MiniMax M2.7**
|
||||
- **Qwen3.8 Max**
|
||||
- **Qwen3.7 Max**
|
||||
- **Qwen3.7 Plus**
|
||||
- **Qwen3.6 Plus**
|
||||
- **DeepSeek V4 Pro**
|
||||
- **DeepSeek V4 Flash**
|
||||
- **Hy3**
|
||||
|
||||
The list of models may change as we test and add new ones.
|
||||
|
||||
## Usage limits
|
||||
|
||||
OpenCode Go includes the following limits:
|
||||
|
||||
- **5 hour limit** — $12 of usage
|
||||
- **Weekly limit** — $30 of usage
|
||||
- **Monthly limit** — $60 of usage
|
||||
|
||||
Limits are defined in dollar value. Your actual request count depends on the model you use. Cheaper models like DeepSeek V4 Flash allow for more requests, while higher-cost models like GLM-5.2 allow for fewer.
|
||||
|
||||
The table below provides an estimated request count based on typical Go usage patterns:
|
||||
|
||||
| Model | Requests per 5 hours | Requests per week | Requests per month |
|
||||
| ----------------- | -------------------- | ----------------- | ------------------ |
|
||||
| Grok 4.5 | 120 | 300 | 600 |
|
||||
| GPT 5.6 Luna | 2,050 | 5,100 | 10,250 |
|
||||
| GLM-5.2 | 880 | 2,150 | 4,300 |
|
||||
| GLM-5.1 | 880 | 2,150 | 4,300 |
|
||||
| Kimi K3 | 110 | 250 | 490 |
|
||||
| Kimi K2.7 Code | 1,350 | 3,380 | 6,750 |
|
||||
| Kimi K2.6 | 1,150 | 2,880 | 5,750 |
|
||||
| MiMo-V2.5 | 30,100 | 75,200 | 150,400 |
|
||||
| MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 |
|
||||
| MiniMax M3 | 3,200 | 8,000 | 16,000 |
|
||||
| MiniMax M2.7 | 3,400 | 8,500 | 17,000 |
|
||||
| Qwen3.8 Max | 160 | 400 | 810 |
|
||||
| Qwen3.7 Max | 340 | 840 | 1,690 |
|
||||
| Qwen3.7 Plus | 4,300 | 10,800 | 21,600 |
|
||||
| Qwen3.6 Plus | 3,300 | 8,200 | 16,300 |
|
||||
| DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 |
|
||||
| DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 |
|
||||
| Hy3 | 4,300 | 10,750 | 21,500 |
|
||||
|
||||
The estimates are based on observed request patterns:
|
||||
|
||||
- Grok 4.5 — 1,100 input, 71,500 cached, 220 output tokens per request
|
||||
- GLM-5.2/5.1 — 700 input, 52,000 cached, 150 output tokens per request
|
||||
- GPT 5.6 Luna — 1,000 input, 50,000 cached, 220 output tokens per request
|
||||
- Kimi K3 — 1,050 input, 76,500 cached, 300 output tokens per request
|
||||
- Kimi K2.7/K2.6 — 870 input, 55,000 cached, 200 output tokens per request
|
||||
- DeepSeek V4 Pro — 750 input, 82,000 cached, 290 output tokens per request
|
||||
- DeepSeek V4 Flash — 790 input, 68,000 cached, 280 output tokens per request
|
||||
- MiniMax M3 — 510 input, 56,000 cached, 190 output tokens per request
|
||||
- MiniMax M2.7 — 300 input, 55,000 cached, 125 output tokens per request
|
||||
- MiMo-V2.5 — 830 input, 71,500 cached, 295 output tokens per request
|
||||
- MiMo-V2.5-Pro — 790 input, 86,000 cached, 305 output tokens per request
|
||||
- Qwen3.8 Max — 420 input, 66,000 cached, 200 output tokens per request
|
||||
- Qwen3.7 Max — 420 input, 66,000 cached, 200 output tokens per request
|
||||
- Qwen3.7 Plus — 500 input, 57,000 cached, 190 output tokens per request
|
||||
- Qwen3.6 Plus — 500 input, 57,000 cached, 190 output tokens per request
|
||||
- Hy3 — 830 input, 71,500 cached, 295 output tokens per request
|
||||
|
||||
The estimates are also based on the following prices per 1M tokens and the monthly usage included with each model:
|
||||
|
||||
<div class="docs-table-scroll" role="region" aria-label="Go model pricing" tabIndex={0}>
|
||||
|
||||
| Model | Input | Output | Cached Read | Cached Write | Usage |
|
||||
| ---------------------------- | ------ | ------ | ----------- | ------------ | ----- |
|
||||
| Grok 4.5 | $2.00 | $6.00 | $0.30 | - | $15 |
|
||||
| GPT 5.6 Luna (≤ 272K tokens) | $0.20 | $1.20 | $0.02 | $0.25 | $15 |
|
||||
| GPT 5.6 Luna (> 272K tokens) | $0.40 | $1.80 | $0.04 | $0.50 | $15 |
|
||||
| GLM-5.2 | $1.40 | $4.40 | $0.26 | - | $60 |
|
||||
| GLM-5.1 | $1.40 | $4.40 | $0.26 | - | $60 |
|
||||
| Kimi K3 | $3.00 | $15.00 | $0.30 | - | $15 |
|
||||
| Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - | $60 |
|
||||
| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | $60 |
|
||||
| MiMo V2.5 | $0.14 | $0.28 | $0.0028 | - | $60 |
|
||||
| MiMo V2.5 Pro | $0.435 | $0.87 | $0.003625 | - | $15 |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - | $60 |
|
||||
| MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | $60 |
|
||||
| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | $60 |
|
||||
| Qwen3.8 Max | $2.00 | $6.00 | $0.25 | $2.50 | $15 |
|
||||
| Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | $60 |
|
||||
| Qwen3.7 Plus (≤ 256K tokens) | $0.40 | $1.60 | $0.04 | $0.50 | $60 |
|
||||
| Qwen3.7 Plus (> 256K tokens) | $1.20 | $4.80 | $0.12 | $1.50 | $60 |
|
||||
| Qwen3.6 Plus (≤ 256K tokens) | $0.50 | $3.00 | $0.05 | $0.625 | $60 |
|
||||
| Qwen3.6 Plus (> 256K tokens) | $2.00 | $6.00 | $0.20 | $2.50 | $60 |
|
||||
| DeepSeek V4 Pro | $0.435 | $0.87 | $0.003625 | - | $15 |
|
||||
| DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | $60 |
|
||||
| Hy3 | $0.14 | $0.58 | $0.035 | - | $60 |
|
||||
|
||||
</div>
|
||||
|
||||
You can track your current usage in the [console](https://console.opencode.ai).
|
||||
|
||||
<Callout type="tip">If you reach the usage limit, you can continue using the free models.</Callout>
|
||||
|
||||
Usage limits may change as we learn from early usage and feedback.
|
||||
|
||||
### Usage beyond limits
|
||||
|
||||
If you also have credits on your Console balance, you can enable the **Use balance** option in the console. When enabled, Go will fall back to your [pay-as-you-go balance](/console/models#pricing) after you've reached your usage limits instead of blocking requests.
|
||||
|
||||
### Why some models have lower usage
|
||||
|
||||
With Go, you pay $10/month and we aim to give you 6x that in usage.
|
||||
|
||||
- For most models, we make this work through bulk discounts and reserved GPU capacity. We pass those savings on to you through the 6x multiplier.
|
||||
- For some models, we haven't had the opportunity to negotiate a discount or host them at a lower cost, either because the model is new or because their public pricing is already discounted.
|
||||
- For these models, you still get a little more than if you paid the model providers directly. This is why their usage multiplier is lower in the table above.
|
||||
|
||||
## Endpoints
|
||||
|
||||
You can also access Go models through the following API endpoints.
|
||||
|
||||
<div class="docs-table-scroll" role="region" aria-label="Go model endpoints" tabIndex={0}>
|
||||
|
||||
| Model | Model ID | Endpoint | AI SDK Package |
|
||||
| ----------------- | ----------------- | ------------------------------------------------ | --------------------------- |
|
||||
| Grok 4.5 | grok-4.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GPT 5.6 Luna | gpt-5.6-luna | `https://opencode.ai/zen/go/v1/responses` | `@ai-sdk/openai` |
|
||||
| GLM-5.2 | glm-5.2 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM-5.1 | glm-5.1 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K3 | kimi-k3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Pro | deepseek-v4-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 | mimo-v2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M3 | minimax-m3 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.8 Max | qwen3.8-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.7 Plus | qwen3.7-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Hy3 | hy3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
|
||||
</div>
|
||||
|
||||
These AI SDK packages are for applications calling Go directly. To use Go in OpenCode, connect with `/connect` and set
|
||||
the [model](/models) using the format `opencode-go/<model-id>`:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"model": "opencode-go/kimi-k3",
|
||||
}
|
||||
```
|
||||
|
||||
### Models
|
||||
|
||||
Fetch the full list of available models and their metadata from the models endpoint:
|
||||
|
||||
```bash
|
||||
curl https://opencode.ai/zen/go/v1/models
|
||||
```
|
||||
|
||||
## Privacy
|
||||
|
||||
| Model | Model training | Data retention |
|
||||
| ----------------- | -------------- | -------------- |
|
||||
| Grok 4.5 | Not used | 30 days |
|
||||
| GPT 5.6 Luna | Not used | 30 days |
|
||||
| GLM-5.2 | Not used | 0 days |
|
||||
| GLM-5.1 | Not used | 0 days |
|
||||
| Kimi K3 | Not used | 0 days |
|
||||
| Kimi K2.7 Code | Not used | 0 days |
|
||||
| Kimi K2.6 | Not used | 0 days |
|
||||
| MiMo-V2.5-Pro | Not used | 0 days |
|
||||
| MiMo-V2.5 | Not used | 0 days |
|
||||
| Qwen3.8 Max | Not used | 0 days |
|
||||
| Qwen3.7 Max | Not used | 0 days |
|
||||
| Qwen3.7 Plus | Not used | 0 days |
|
||||
| Qwen3.6 Plus | Not used | 0 days |
|
||||
| MiniMax M3 | Not used | 0 days |
|
||||
| MiniMax M2.7 | Not used | 0 days |
|
||||
| DeepSeek V4 Pro | Not used | 0 days |
|
||||
| DeepSeek V4 Flash | Not used | 0 days |
|
||||
| Hy3 | Not used | 0 days |
|
||||
|
||||
- **Grok 4.5:** ZDR disables important API features that depend on stored data, including the stateful Responses API, Files and Collections, and the Batch API. [Learn more](https://docs.x.ai/developers/faq/security#what-is-zero-data-retention-zdr).
|
||||
- **GPT 5.6 Luna:** Abuse monitoring logs are generated for all API feature usage and retained for up to 30 days. [Learn more](https://developers.openai.com/api/docs/guides/your-data#data-retention-controls-for-abuse-monitoring).
|
||||
- **DeepSeek V4 Flash:** ZDR agreement is renewed monthly. The current agreement is valid through August 31, 2026.
|
||||
|
||||
## Background
|
||||
|
||||
Open models have gotten really good. They now reach performance close to proprietary models for coding tasks. Because many providers can serve them competitively, they are usually far cheaper.
|
||||
|
||||
However, getting reliable, low latency access to them can be difficult. Providers vary in quality and availability.
|
||||
|
||||
<Callout type="tip">We tested a select group of models and providers that work well with OpenCode.</Callout>
|
||||
|
||||
To fix this, we did a couple of things:
|
||||
|
||||
1. We tested a select group of open models and talked to their teams about how to best run them.
|
||||
2. We worked with a few providers to make sure these were being served correctly.
|
||||
3. We benchmarked the combination of the model/provider and came up with a list that we feel good recommending.
|
||||
|
||||
OpenCode Go gives you access to these models for **$5 for your first month**, then **$10/month**.
|
||||
|
||||
## Goals
|
||||
|
||||
We created OpenCode Go to:
|
||||
|
||||
1. Make AI coding **accessible** to more people with a low cost subscription.
|
||||
2. Provide **reliable** access to the best open coding models.
|
||||
3. Curate models that are **tested and benchmarked** for coding agent use.
|
||||
4. Have **no lock-in** by allowing you to use any other provider with OpenCode as well.
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
title: "Intro"
|
||||
---
|
||||
|
||||
The [OpenCode Console](https://opencode.ai/console) is an optional service that provides additional benefits to
|
||||
using OpenCode particularly as a team
|
||||
|
||||
- Inference for both proprietary and open source models
|
||||
- LLM Gateway for connecting your own providers
|
||||
- Usage tracking and budget controls
|
||||
- Deploy team wide policies to control OpenCode behavior
|
||||
- Web search
|
||||
- OpenCode Go, $10 subscription for open source model access
|
||||
@@ -0,0 +1,326 @@
|
||||
---
|
||||
title: "Models"
|
||||
description: "Curated coding models, pay-as-you-go pricing, and API access through OpenCode Console."
|
||||
---
|
||||
|
||||
OpenCode Console provides a list of models tested and verified by the OpenCode team. Sign in, add credits, and get an
|
||||
API key to use them with OpenCode or another coding agent.
|
||||
|
||||
Console works like any other provider in OpenCode. It's **completely optional**, and you can use any other provider
|
||||
instead. For a subscription with included usage, see [OpenCode Go](/console/go).
|
||||
|
||||
## How it works
|
||||
|
||||
1. Sign in to the [console](https://console.opencode.ai), add your billing details and credits, and copy your API key.
|
||||
2. Run `/connect` in the TUI, choose the OpenCode pay-as-you-go provider, and paste your API key.
|
||||
|
||||
```text
|
||||
/connect
|
||||
```
|
||||
|
||||
3. Run `/models` to see the available models and select one.
|
||||
|
||||
```text
|
||||
/models
|
||||
```
|
||||
|
||||
You are charged per request and can add credits to your account.
|
||||
|
||||
## Endpoints
|
||||
|
||||
You can also access the models directly through the following API endpoints.
|
||||
|
||||
<div class="docs-table-scroll" role="region" aria-label="Console model endpoints" tabIndex={0}>
|
||||
|
||||
| Model | Model ID | Endpoint | AI SDK Package |
|
||||
| ---------------------- | ---------------------- | --------------------------------------------------------- | --------------------------- |
|
||||
| GPT 5.6 Sol | gpt-5.6-sol | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.6 Terra | gpt-5.6-terra | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.6 Luna | gpt-5.6-luna | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.4 Pro | gpt-5.4-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.4 Mini | gpt-5.4-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.4 Nano | gpt-5.4-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.3 Codex Spark | gpt-5.3-codex-spark | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.1 Codex Mini | gpt-5.1-codex-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| Claude Fable 5 | claude-fable-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Opus 5 | claude-opus-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Opus 4.8 | claude-opus-4-8 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Sonnet 5 | claude-sonnet-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Gemini 3.6 Flash | gemini-3.6-flash | `https://opencode.ai/zen/v1/models/gemini-3.6-flash` | `@ai-sdk/google` |
|
||||
| Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` |
|
||||
| Gemini 3.5 Flash Lite | gemini-3.5-flash-lite | `https://opencode.ai/zen/v1/models/gemini-3.5-flash-lite` | `@ai-sdk/google` |
|
||||
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
|
||||
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
|
||||
| Grok 4.5 | grok-4.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.7 Plus | qwen3.7-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| DeepSeek V4 Pro | deepseek-v4-pro | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M3 | minimax-m3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5.2 | glm-5.2 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K3 | kimi-k3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-tiny Free | ling-3.0-tiny-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| LongCat-2.0 Free | longcat-2.0-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
|
||||
</div>
|
||||
|
||||
These AI SDK packages are for applications calling the endpoints directly. To select a [model](/models) in OpenCode,
|
||||
use the format `opencode/<model-id>`:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"model": "opencode/gpt-5.5",
|
||||
}
|
||||
```
|
||||
|
||||
### Model list
|
||||
|
||||
Fetch the full list of available models and their metadata from the models endpoint:
|
||||
|
||||
```bash
|
||||
curl https://opencode.ai/zen/v1/models
|
||||
```
|
||||
|
||||
## Pricing
|
||||
|
||||
Console uses pay-as-you-go pricing. Below are the prices **per 1M tokens**.
|
||||
|
||||
<div class="docs-table-scroll" role="region" aria-label="Console model pricing" tabIndex={0}>
|
||||
|
||||
| Model | Input | Output | Cached Read | Cached Write |
|
||||
| --------------------------------- | ------ | ------- | ----------- | ------------ |
|
||||
| Big Pickle | Free | Free | Free | - |
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-tiny Free | Free | Free | Free | - |
|
||||
| LongCat-2.0 Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
| MiniMax M2.7 | $0.30 | $1.20 | $0.06 | - |
|
||||
| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | - |
|
||||
| GLM 5.2 | $1.40 | $4.40 | $0.26 | - |
|
||||
| GLM 5.1 | $1.40 | $4.40 | $0.26 | - |
|
||||
| GLM 5 | $1.00 | $3.20 | $0.20 | - |
|
||||
| Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - |
|
||||
| Kimi K3 | $3.00 | $15.00 | $0.30 | - |
|
||||
| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - |
|
||||
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
|
||||
| Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 |
|
||||
| Qwen3.7 Plus | $0.40 | $1.60 | $0.04 | $0.50 |
|
||||
| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 |
|
||||
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
|
||||
| DeepSeek V4 Pro | $1.74 | $3.48 | $0.145 | - |
|
||||
| DeepSeek V4 Flash | $0.14 | $0.28 | $0.028 | - |
|
||||
| Claude Fable 5 | $10.00 | $50.00 | $1.00 | $12.50 |
|
||||
| Claude Opus 5 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||
| Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||
| Claude Sonnet 5 | $2.00 | $10.00 | $0.20 | $2.50 |
|
||||
| Claude Sonnet 4.6 | $3.00 | $15.00 | $0.30 | $3.75 |
|
||||
| Claude Sonnet 4.5 (≤ 200K tokens) | $3.00 | $15.00 | $0.30 | $3.75 |
|
||||
| Claude Sonnet 4.5 (> 200K tokens) | $6.00 | $22.50 | $0.60 | $7.50 |
|
||||
| Claude Haiku 4.5 | $1.00 | $5.00 | $0.10 | $1.25 |
|
||||
| Gemini 3.6 Flash | $1.50 | $7.50 | $0.15 | - |
|
||||
| Gemini 3.5 Flash | $1.50 | $9.00 | $0.15 | - |
|
||||
| Gemini 3.5 Flash Lite | $0.30 | $2.50 | $0.03 | - |
|
||||
| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
|
||||
| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
|
||||
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
|
||||
| Grok 4.5 (≤ 200K tokens) | $2.00 | $6.00 | $0.30 | - |
|
||||
| Grok 4.5 (> 200K tokens) | $4.00 | $12.00 | $0.60 | - |
|
||||
| Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - |
|
||||
| GPT 5.6 Sol (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | $6.25 |
|
||||
| GPT 5.6 Sol (> 272K tokens) | $10.00 | $45.00 | $1.00 | $12.50 |
|
||||
| GPT 5.6 Terra (≤ 272K tokens) | $2.00 | $12.00 | $0.20 | $2.50 |
|
||||
| GPT 5.6 Terra (> 272K tokens) | $4.00 | $18.00 | $0.40 | $5.00 |
|
||||
| GPT 5.6 Luna (≤ 272K tokens) | $0.20 | $1.20 | $0.02 | $0.25 |
|
||||
| GPT 5.6 Luna (> 272K tokens) | $0.40 | $1.80 | $0.04 | $0.50 |
|
||||
| GPT 5.5 (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | - |
|
||||
| GPT 5.5 (> 272K tokens) | $10.00 | $45.00 | $1.00 | - |
|
||||
| GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - |
|
||||
| GPT 5.4 (≤ 272K tokens) | $2.50 | $15.00 | $0.25 | - |
|
||||
| GPT 5.4 (> 272K tokens) | $5.00 | $22.50 | $0.50 | - |
|
||||
| GPT 5.4 Pro | $30.00 | $180.00 | $30.00 | - |
|
||||
| GPT 5.4 Mini | $0.75 | $4.50 | $0.075 | - |
|
||||
| GPT 5.4 Nano | $0.20 | $1.25 | $0.02 | - |
|
||||
| GPT 5.3 Codex Spark | $1.75 | $14.00 | $0.175 | - |
|
||||
| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - |
|
||||
| GPT 5.2 | $1.75 | $14.00 | $0.175 | - |
|
||||
| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - |
|
||||
| GPT 5.1 | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5.1 Codex | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5.1 Codex Max | $1.25 | $10.00 | $0.125 | - |
|
||||
| GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - |
|
||||
| GPT 5 | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5 Codex | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - |
|
||||
|
||||
</div>
|
||||
|
||||
You may notice [low-cost models](/models), such as Haiku, Nano, or Flash, in your usage history. OpenCode uses these
|
||||
models to generate session titles.
|
||||
|
||||
<Callout>
|
||||
Credit card fees are passed along at cost (4.4% + $0.30 per transaction); we don't charge anything beyond that.
|
||||
</Callout>
|
||||
|
||||
### Free models
|
||||
|
||||
These models are available for a limited time while their teams collect feedback and improve them:
|
||||
|
||||
- DeepSeek V4 Flash Free
|
||||
- MiMo-V2.5 Free
|
||||
- Laguna S 2.1 Free
|
||||
- Ling-3.0-tiny Free
|
||||
- LongCat-2.0 Free
|
||||
- North Mini Code Free
|
||||
- Nemotron 3 Ultra Free
|
||||
- Big Pickle, a stealth model
|
||||
|
||||
[Contact us](mailto:help@anoma.ly) if you have any questions.
|
||||
|
||||
### Auto-reload
|
||||
|
||||
If your balance goes below $5, Console automatically reloads $20. You can change the auto-reload amount or disable
|
||||
auto-reload entirely.
|
||||
|
||||
### Monthly limits
|
||||
|
||||
You can set a monthly usage limit for the entire workspace and for each member of your team.
|
||||
|
||||
For example, with a $20 monthly usage limit, Console will not use more than $20 in a month. If auto-reload is enabled,
|
||||
you might still be charged more than $20 when your balance goes below $5.
|
||||
|
||||
### Deprecated models
|
||||
|
||||
| Model | Deprecation date |
|
||||
| ------------------ | ----------------- |
|
||||
| GPT 5.2 Codex | July 23, 2026 |
|
||||
| GPT 5.1 Codex | July 23, 2026 |
|
||||
| GPT 5.1 Codex Max | July 23, 2026 |
|
||||
| GPT 5.1 Codex Mini | July 23, 2026 |
|
||||
| GPT 5 Codex | July 23, 2026 |
|
||||
| Claude Opus 4.1 | August 5, 2026 |
|
||||
| Claude Sonnet 4 | June 15, 2026 |
|
||||
| Claude Haiku 3.5 | February 16, 2026 |
|
||||
| Gemini 3 Pro | March 9, 2026 |
|
||||
| MiniMax M2.5 | August 5, 2026 |
|
||||
| MiniMax M2.1 | March 15, 2026 |
|
||||
| GLM 5 | May 14, 2026 |
|
||||
| GLM 4.7 | March 15, 2026 |
|
||||
| GLM 4.6 | March 15, 2026 |
|
||||
| Kimi K2.5 | August 5, 2026 |
|
||||
| Kimi K2 Thinking | March 6, 2026 |
|
||||
| Kimi K2 | March 6, 2026 |
|
||||
| Qwen3 Coder 480B | February 6, 2026 |
|
||||
|
||||
## Privacy
|
||||
|
||||
All these models are hosted in the US. Providers follow a zero-retention policy and do not use your data for model
|
||||
training, with the following exceptions:
|
||||
|
||||
- **Big Pickle:** During its free period, collected data may be used to improve the model.
|
||||
- **DeepSeek V4 Flash Free:** During its free period, collected data may be used to improve the model.
|
||||
- **MiMo-V2.5 Free:** During its free period, collected data may be used to improve the model.
|
||||
- **Laguna S 2.1 Free:** During its free period, collected data may be used to improve the model.
|
||||
- **Ling-3.0-tiny Free:** During its free period, collected data may be used to improve the model.
|
||||
- **LongCat-2.0 Free:** During its free period, collected data may be used to improve the model.
|
||||
- **North Mini Code Free:** During its free period, collected data may be retained and used to improve the model. Do not submit personal or confidential data. See the provider's [Terms of Use](https://cohere.com/terms-of-use) and [Privacy Policy](https://cohere.com/privacy).
|
||||
- **Nemotron 3 Ultra Free (NVIDIA free endpoints):** Trial use only — do not submit personal or confidential data. Your use is logged for security purposes and to improve NVIDIA products and services. The logged session data for improvement purposes is not linked to your identity or any persistent identifier. For more information about data processing practices, see the [Privacy Policy](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). By interacting with this endpoint, you consent to the collection, recording, and use of such information and the [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf).
|
||||
- **OpenAI APIs:** Requests are retained for 30 days in accordance with [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
- **Anthropic APIs:** Requests are retained for 30 days in accordance with [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage).
|
||||
|
||||
For subscription models, see [Go privacy](/console/go#privacy).
|
||||
|
||||
## For teams
|
||||
|
||||
You can invite teammates, assign roles, and curate the models your team uses.
|
||||
|
||||
<Callout>
|
||||
Managing workspaces is currently free for teams as part of the beta. More pricing details will be shared later.
|
||||
</Callout>
|
||||
|
||||
### Roles
|
||||
|
||||
Invite teammates to your workspace and assign roles:
|
||||
|
||||
- **Admin:** Manage models, members, API keys, and billing.
|
||||
- **Member:** Manage only their own API keys.
|
||||
|
||||
Admins can also set monthly spending limits for each member to keep costs under control.
|
||||
|
||||
### Model access
|
||||
|
||||
Admins can enable or disable specific models for the workspace. Requests made to a disabled model return an error.
|
||||
|
||||
For example, you can disable a model that collects data so members of your workspace cannot use it.
|
||||
|
||||
### Bring your own key
|
||||
|
||||
You can use your own OpenAI or Anthropic API keys while still accessing other models through Console. Tokens used
|
||||
with your own keys are billed directly by the provider.
|
||||
|
||||
For example, if your organization already has an OpenAI API key, you can use it instead of the one Console provides.
|
||||
|
||||
## Background
|
||||
|
||||
There are many models available, but only a few work well as coding agents. Providers also vary in configuration,
|
||||
performance, and quality, so a model accessed through a gateway such as OpenRouter may perform differently.
|
||||
|
||||
<Callout type="tip">We tested a select group of models and providers that work well with OpenCode.</Callout>
|
||||
|
||||
To build this catalog, we:
|
||||
|
||||
1. Tested a select group of models and talked to their teams about how to best run them.
|
||||
2. Worked with providers to make sure the models were served correctly.
|
||||
3. Benchmarked each model/provider combination to produce a list we feel good recommending.
|
||||
|
||||
Console is an AI gateway that gives you access to these models.
|
||||
|
||||
## Goals
|
||||
|
||||
We built Console to:
|
||||
|
||||
1. **Benchmark** the best models and providers for coding agents.
|
||||
2. Provide the **highest quality** options without downgrading performance or routing to cheaper providers.
|
||||
3. Pass along **price drops** by selling at cost, with markup only to cover processing fees.
|
||||
4. Have **no lock-in**: use Console with any coding agent, or use another provider with OpenCode.
|
||||
@@ -35,9 +35,9 @@ directly [in the TUI](/cli/providers) with `/connect`.
|
||||
See [Providers](/providers) to configure custom providers.
|
||||
|
||||
If you'd like easy access to all the best coding models you can try out
|
||||
[OpenCode Console](https://console.opencode.ai).
|
||||
[OpenCode Console](/console).
|
||||
|
||||
You can also try [OpenCode Go](https://opencode.ai/go) a $10/month subscription
|
||||
You can also try [OpenCode Go](/console/go) a $10/month subscription
|
||||
plan that grants you access to the best open source models.
|
||||
|
||||
---
|
||||
|
||||
@@ -38,6 +38,19 @@ The `providers` object is keyed by provider ID. Each provider accepts these fiel
|
||||
| `body` | JSON fields merged into request bodies. |
|
||||
| `models` | Models to add or override, keyed by catalog model ID. |
|
||||
|
||||
## OpenCode Go
|
||||
|
||||
[OpenCode Go](/console/go) is an optional subscription that provides access to coding models tested by the OpenCode team.
|
||||
Subscribe in the [console](https://console.opencode.ai), copy your API key, then run `/connect` in the TUI and select
|
||||
**OpenCode Go**:
|
||||
|
||||
```text
|
||||
/connect
|
||||
```
|
||||
|
||||
Paste your API key, then run `/models` to select a Go model. See the [Go guide](/console/go) for setup, usage limits,
|
||||
endpoints, and privacy details.
|
||||
|
||||
## Azure OpenAI and Microsoft Foundry
|
||||
|
||||
Azure supports either an API key or your existing Microsoft Entra ID session from the Azure CLI.
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface DocsNavGroup {
|
||||
}
|
||||
|
||||
export interface DocsSection {
|
||||
key: "docs" | "cli" | "build" | "api"
|
||||
key: "docs" | "cli" | "build" | "api" | "console"
|
||||
title: string
|
||||
landingSlug: string
|
||||
groups: DocsNavGroup[]
|
||||
@@ -131,6 +131,20 @@ export const docsSections: DocsSection[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "console",
|
||||
title: "Console",
|
||||
landingSlug: "console",
|
||||
groups: [
|
||||
{
|
||||
items: [
|
||||
{ title: "Intro", slug: "console" },
|
||||
{ title: "Models", slug: "console/models" },
|
||||
{ title: "Go", slug: "console/go" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function docsHref(slug: string, anchor?: string) {
|
||||
|
||||
@@ -904,6 +904,10 @@ main {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.docs-table-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.prose table {
|
||||
width: 100%;
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
Reference in New Issue
Block a user