Compare commits

..
1133 changed files with 27848 additions and 24042 deletions
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Nested AGENTS.md instructions are re-injected after compaction. Previously the in-memory dedup claim outlived the synthetic message that compaction dropped from model-visible history, so nested instructions were silently lost for the rest of the process lifetime. The claim now only guards in-flight loads; the synthetic message metadata in durable history is the sole lasting ledger, so any history truncation (compaction, revert) self-heals on the next read in that subtree.
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Title generation and compaction summaries now build their model requests through the shared session request boundary, gaining unsupported-media filtering and image bounds while explicitly opting out of session context hooks: plugins that shape the agent conversation do not observe title or compaction requests. Title requests gain the fork-aware session prompt cache key, and compaction summaries in forked sessions reuse the fork root's prompt cache key instead of the fork's own.
-37
View File
@@ -1,37 +0,0 @@
name: deploy-posts
on:
push:
branches:
- v2
paths:
- packages/posts/**
- bun.lock
- .github/workflows/deploy-posts.yml
workflow_dispatch:
concurrency:
group: deploy-posts-${{ github.ref_name }}
cancel-in-progress: false
permissions:
contents: read
jobs:
deploy:
if: github.repository == 'anomalyco/opencode' && github.ref_name == 'v2'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ./.github/actions/setup-bun
- name: Build
working-directory: packages/posts
run: bun run build
- name: Deploy
working-directory: packages/posts
run: bun run deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
-8
View File
@@ -50,14 +50,6 @@ jobs:
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Test Effect simplification rules
if: runner.os == 'Linux'
run: bun run test:effect-simplification-rules
- name: Check Effect simplifications
if: runner.os == 'Linux'
run: bun run lint:effect-simplifications
- name: Configure git identity
run: |
git config --global user.email "bot@opencode.ai"
File diff suppressed because it is too large Load Diff
+17 -1
View File
@@ -1,3 +1,19 @@
{
"$schema": "https://opencode.ai/tui.json"
"$schema": "https://opencode.ai/tui.json",
"plugin": [
[
"./plugins/tui-smoke.tsx",
{
"enabled": false,
"label": "workspace",
"keybinds": {
"smoke_modal": "ctrl+alt+m",
"smoke_screen": "ctrl+alt+o",
"smoke_screen_home": "escape,ctrl+shift+h",
"smoke_screen_modal": "ctrl+alt+m",
"smoke_dialog_close": "escape,q"
}
}
]
]
}
+1 -1
View File
@@ -1,5 +1,5 @@
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit generated client files directly.
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk` composes Client, Core, and Server.
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server.
- Current implementation changes belong in `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
- The default branch in this repo is `v2`.
- Base all new branches and worktrees on `v2`, or `origin/v2` when the local `v2` ref is unavailable. Do not base them on `dev`.
+1639 -1525
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-PatsUdaitHvSUpS5gkC5J2rsUNB5vwJKqHdlOFaKk70=",
"aarch64-linux": "sha256-gTRQMAADH/SpQ8yh+YS2IcnmQxnJcU4FUah2YfrKeP8=",
"aarch64-darwin": "sha256-QTqlwmugYh+iu5Sh/Hxv01NXH/OhzcQ8ObVUbA9A8AM=",
"x86_64-darwin": "sha256-0DPAbNCVw2nUMWkIGEhB6saMdxRRwAJi7wAoWCQc7xQ="
"x86_64-linux": "sha256-9IJxoe/MdL6nmoeKvxZop+77HJ/b3HbmpytNUvLqYkc=",
"aarch64-linux": "sha256-KR1J102RDTRDZIkAD3jQfXeP1G2DN9Es7KkdKo+daec=",
"aarch64-darwin": "sha256-HCgSoq1W6XU6m73Ck3wV8gjB687caUTM3w/EO+V7wsE=",
"x86_64-darwin": "sha256-GrKTDvjg0XeDIWbOvayWQjj0SdJd8WPm2+q3IVS17Jg="
}
}
+8 -9
View File
@@ -15,28 +15,26 @@
"dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev",
"dev:www": "bun run --cwd packages/www dev",
"dev:storybook": "bun --cwd packages/storybook storybook",
"bench:devex": "bun run --cwd packages/app test:bench:devex",
"lint": "oxlint",
"lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/util/src packages/core/src packages/server/src packages/protocol/src packages/cli/src",
"lint:effect-simplifications": "ast-grep scan -c script/ast-grep/effect-simplifications/sgconfig.yml --off=unused-suppression packages",
"test:lint-rules": "ast-grep test -c script/ast-grep/sgconfig.yml",
"test:effect-simplification-rules": "ast-grep test -c script/ast-grep/effect-simplifications/sgconfig.yml",
"typecheck": "bun turbo typecheck --concurrency=3",
"typecheck:profile": "bun script/profile-typecheck.ts",
"typecheck:profile:packages": "bun script/profile-typecheck-packages.ts",
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
"postinstall": "bun run --cwd packages/core fix-node-pty",
"prepare": "husky",
"reserve-packages": "bun script/reserve-package-names.ts",
"random": "echo 'Random script'",
"sso": "aws sso login --sso-session=opencode --no-browser",
"translate:app": "bun run script/translate-app.ts",
"test": "echo 'do not run tests from root' && exit 1"
},
"workspaces": {
"packages": [
"packages/*",
"packages/console/*",
"packages/stats/*"
"packages/stats/*",
"packages/slack"
],
"catalog": {
"@effect/opentelemetry": "4.0.0-rc.110",
@@ -49,9 +47,9 @@
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.5.6",
"@opentui/keymap": "0.5.6",
"@opentui/solid": "0.5.6",
"@opentui/core": "0.5.4",
"@opentui/keymap": "0.5.4",
"@opentui/solid": "0.5.4",
"@tanstack/solid-virtual": "3.13.32",
"@shikijs/stream": "4.2.0",
"@standard-schema/spec": "1.1.0",
@@ -129,6 +127,7 @@
"@aws-sdk/client-s3": "3.933.0",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "1.18.5",
"heap-snapshot-toolkit": "1.1.3",
"typescript": "catalog:"
},
@@ -175,6 +174,6 @@
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch"
"@ff-labs/fff-bun@0.10.1": "patches/@ff-labs%2Ffff-bun@0.10.1.patch"
}
}
+2 -1
View File
@@ -309,7 +309,7 @@ Included providers: OpenAI, Anthropic, Google (Gemini), Google Vertex Gemini and
### Package-like entrypoints
Native catalog integrations load provider behavior through package-like entrypoints. These are export paths from the same `@opencode-ai/ai` npm package, not independently published packages. Each entrypoint exports the same `model(modelID, settings)` contract, and `settings` contains serializable provider configuration plus common `headers` and `body` overlays.
Native catalog integrations load provider behavior through package-like entrypoints. These are export paths from the same `@opencode-ai/ai` npm package, not independently published packages. Each entrypoint exports the same `model(modelID, settings)` contract, and `settings` contains serializable provider configuration plus common `headers`, `body`, and `limits` overlays.
```ts
import { model } from "@opencode-ai/ai/providers/openai/responses"
@@ -317,6 +317,7 @@ import { model } from "@opencode-ai/ai/providers/openai/responses"
const selected = model("gpt-5", {
apiKey: process.env.OPENAI_API_KEY,
headers: { "x-application": "opencode" },
limits: { context: 200_000, output: 64_000 },
})
```
+1 -1
View File
@@ -370,7 +370,7 @@ const responseError = Effect.fn("RecordingEnv.responseError")(function* (
response: HttpClientResponse.HttpClientResponse,
) {
if (response.status >= 200 && response.status < 300) return undefined
const body = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
const body = yield* response.text.pipe(Effect.catch(() => Effect.succeed("")))
return `${response.status}${body ? `: ${body.slice(0, 180)}` : ""}`
})
@@ -31,19 +31,6 @@ import { ToolStream } from "./utils/tool-stream.js"
const ADAPTER = "anthropic-messages"
export const DEFAULT_BASE_URL = "https://api.anthropic.com/v1"
export const PATH = "/messages"
export const DEFAULT_MAX_TOKENS = 32_000
const SSE_EVENTS = new Set([
"message",
"message_start",
"message_delta",
"message_stop",
"content_block_start",
"content_block_delta",
"content_block_stop",
"error",
])
export const framing = Framing.sseEvents(SSE_EVENTS)
export type ThinkingInput =
| {
@@ -246,7 +233,7 @@ export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesB
const AnthropicUsage = Schema.StructWithRest(
Schema.Struct({
input_tokens: optionalNull(Schema.Number),
input_tokens: Schema.optional(Schema.Number),
output_tokens: Schema.optional(Schema.Number),
cache_creation_input_tokens: optionalNull(Schema.Number),
cache_read_input_tokens: optionalNull(Schema.Number),
@@ -374,18 +361,16 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
tool: (name) => ({ type: "tool" as const, name }),
})
const scrubToolCallID = (id: string) => id.replace(/[^a-zA-Z0-9_-]/g, "_")
const lowerToolCall = (part: ToolCallPart): AnthropicToolUseBlock => ({
type: "tool_use",
id: scrubToolCallID(part.id),
id: part.id,
name: part.name,
input: part.input,
})
const lowerServerToolCall = (part: ToolCallPart): AnthropicServerToolUseBlock => ({
type: "server_tool_use",
id: scrubToolCallID(part.id),
id: part.id,
name: part.name,
input: part.input,
})
@@ -407,7 +392,7 @@ const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult
// Prefer the provider-owned replay payload; fall back to the result value for
// histories constructed directly from provider events.
const payload = part.providerMetadata?.anthropic?.["result"] ?? part.result.value
return { type: wireType, tool_use_id: scrubToolCallID(part.id), content: payload } satisfies AnthropicServerToolResultBlock
return { type: wireType, tool_use_id: part.id, content: payload } satisfies AnthropicServerToolResultBlock
})
const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: MediaPart) {
@@ -589,7 +574,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
return yield* ProviderShared.unsupportedContent("Anthropic Messages", "tool", ["tool-result"])
content.push({
type: "tool_result",
tool_use_id: scrubToolCallID(part.id),
tool_use_id: part.id,
content: yield* lowerToolResultContent(part),
is_error: part.result.type === "error" ? true : undefined,
cache_control: cacheControl(breakpoints, part.cache),
@@ -639,6 +624,7 @@ const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function*
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const outputLimit = request.model.defaults?.limits?.output ?? request.model.route.defaults.limits?.output ?? 4096
// Allocate the 4-breakpoint budget in invalidation order: tools → system →
// messages. Tools live highest in the cache hierarchy, so when callers
// over-mark we keep their tool hints and shed the message-tail ones first.
@@ -677,7 +663,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
tools,
tool_choice: toolChoice,
stream: true as const,
max_tokens: generation?.maxTokens ?? DEFAULT_MAX_TOKENS,
max_tokens: generation?.maxTokens ?? outputLimit,
temperature: generation?.temperature,
top_p: generation?.topP,
top_k: generation?.topK,
@@ -706,7 +692,7 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
// expose that subset through `output_tokens_details.thinking_tokens`.
const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => {
if (!usage) return undefined
const nonCached = usage.input_tokens ?? undefined
const nonCached = usage.input_tokens
const cacheRead = usage.cache_read_input_tokens ?? undefined
const cacheWrite = usage.cache_creation_input_tokens ?? undefined
const inputTokens = ProviderShared.sumTokens(nonCached, cacheRead, cacheWrite)
@@ -1053,7 +1039,7 @@ export const route = Route.make({
protocol,
endpoint: Endpoint.path(PATH, { baseURL: DEFAULT_BASE_URL }),
auth: Auth.none,
framing,
framing: Framing.sse,
headers: () => ({ "anthropic-version": "2023-06-01" }),
})
+16 -52
View File
@@ -37,10 +37,6 @@ const requiresThoughtSignatureFallback = (modelID: string) => {
return !/(^|\/)gemini-robotics-er-1\.5(?:[.-]|$)/i.test(modelID)
}
// Gemini 3 accepts media nested inside function responses; matched Gemini 2.5 variants reject it,
// so their tool-result attachments lower as a separate user turn instead.
const routesLegacyToolMedia = (modelID: string) => /gemini-2[.-]5(?:[.-]|$)/i.test(modelID)
export interface OptionsInput {
readonly [key: string]: unknown
readonly cachedContent?: string
@@ -217,7 +213,6 @@ interface ParserState {
readonly usage?: Usage
readonly lifecycle: Lifecycle.State
readonly reasoningSignature?: string
readonly textSignature?: string
}
// =============================================================================
@@ -289,22 +284,12 @@ const lowerToolCall = (part: ToolCallPart) => ({
const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMRequest) {
const contents: GeminiContent[] = []
const legacyToolMedia = routesLegacyToolMedia(request.model.id)
let pendingMedia: GeminiInlineDataPart[] | undefined
const flushMedia = () => {
if (!pendingMedia) return
contents.push({ role: "user", parts: [{ text: "Attached media from tool result:" }, ...pendingMedia] })
pendingMedia = undefined
}
for (const message of request.messages) {
if (message.role !== "tool") flushMedia()
if (message.role === "system") {
const part = yield* ProviderShared.wrappedSystemUpdate("Gemini", message)
const previous = contents.at(-1)
// Gemini rejects a continuation whose function-response turn carries extra
// parts, so an update after a tool result starts its own user turn.
if (previous?.role === "user" && !previous.parts.some((item) => "functionResponse" in item))
if (previous?.role === "user")
contents[contents.length - 1] = { role: "user", parts: [...previous.parts, { text: part.text }] }
else contents.push({ role: "user", parts: [{ text: part.text }] })
continue
@@ -329,7 +314,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
if (!ProviderShared.supportsContent(part, ["text", "reasoning", "tool-call"]))
return yield* ProviderShared.unsupportedContent("Gemini", "assistant", ["text", "reasoning", "tool-call"])
if (part.type === "text") {
parts.push({ text: part.text, thoughtSignature: thoughtSignature(part.providerMetadata) })
parts.push({ text: part.text })
continue
}
if (part.type === "reasoning") {
@@ -380,7 +365,6 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
const value = ProviderShared.normalizeToolFile(item)
media.push({ inlineData: { mimeType: value.mime, data: value.base64 } })
}
if (legacyToolMedia && media.length > 0) (pendingMedia ??= []).push(...media)
parts.push({
functionResponse: {
id: functionCallId(part.providerMetadata),
@@ -389,19 +373,13 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
name: part.name,
content: text.join("\n"),
},
parts: legacyToolMedia || media.length === 0 ? undefined : media,
parts: media.length > 0 ? media : undefined,
},
})
}
// Gemini requires every response to a parallel call batch in one user turn,
// so consecutive tool results join the open function-response turn.
const previous = contents.at(-1)
if (previous?.role === "user" && previous.parts.some((item) => "functionResponse" in item))
contents[contents.length - 1] = { role: "user", parts: [...previous.parts, ...parts] }
else contents.push({ role: "user", parts })
contents.push({ role: "user", parts })
}
flushMedia()
return contents
})
@@ -541,16 +519,14 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
if (finishReason === undefined && state.usage === undefined) return []
const events: LLMEvent[] = []
let lifecycle = state.lifecycle
if (state.reasoningSignature !== undefined)
lifecycle = Lifecycle.reasoningEnd(
lifecycle,
events,
"reasoning-0",
googleMetadata({ thoughtSignature: state.reasoningSignature }),
)
if (state.textSignature !== undefined)
lifecycle = Lifecycle.textEnd(lifecycle, events, "text-0", googleMetadata({ thoughtSignature: state.textSignature }))
const lifecycle = state.reasoningSignature
? Lifecycle.reasoningEnd(
state.lifecycle,
events,
"reasoning-0",
googleMetadata({ thoughtSignature: state.reasoningSignature }),
)
: state.lifecycle
Lifecycle.finish(lifecycle, events, {
reason: {
normalized:
@@ -582,14 +558,10 @@ const step = (state: ParserState, event: GeminiEvent) => {
let lifecycle = nextState.lifecycle
let nextToolCallId = nextState.nextToolCallId
let reasoningSignature = nextState.reasoningSignature
let textSignature = nextState.textSignature
for (const part of candidate.content.parts) {
const signature = "thoughtSignature" in part && part.thoughtSignature ? part.thoughtSignature : undefined
// Gemini attaches replay signatures to thought parts, visible text, or function calls;
// each block kind must retain the signature attached to its own parts.
if (signature !== undefined && "thought" in part && part.thought) reasoningSignature = signature
else if (signature !== undefined && "text" in part) textSignature = signature
if ("thoughtSignature" in part && part.thoughtSignature && "thought" in part && part.thought)
reasoningSignature = part.thoughtSignature
if ("text" in part && part.text.length > 0) {
if (part.thought) {
lifecycle = Lifecycle.reasoningDelta(
@@ -597,7 +569,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
events,
"reasoning-0",
part.text,
signature ? googleMetadata({ thoughtSignature: signature }) : undefined,
part.thoughtSignature ? googleMetadata({ thoughtSignature: part.thoughtSignature }) : undefined,
)
continue
}
@@ -607,14 +579,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
"reasoning-0",
reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined,
)
lifecycle = Lifecycle.textDelta(
lifecycle,
events,
"text-0",
part.text,
textSignature ? googleMetadata({ thoughtSignature: textSignature }) : undefined,
)
textSignature = undefined
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", part.text)
continue
}
@@ -651,7 +616,6 @@ const step = (state: ParserState, event: GeminiEvent) => {
lifecycle,
nextToolCallId,
reasoningSignature,
textSignature,
finishReason: candidate.finishReason ?? nextState.finishReason,
},
events,
@@ -22,8 +22,6 @@ export interface Options {
readonly id: string
readonly name: string
readonly rotateAfterMs?: number
readonly enabled?: (url: string) => boolean
readonly url?: (url: string) => string
readonly headers?: (headers: Headers.Headers) => Headers.Headers
readonly driver?: (input: {
readonly request: Readonly<Record<string, unknown>>
@@ -149,19 +147,18 @@ export const transport = <Body>(options: Options): Transport<Body, Prepared, str
Effect.gen(function* () {
const parts = yield* HttpTransport.jsonRequestParts(input)
const headers = Headers.remove(options.headers?.(parts.headers) ?? parts.headers, "content-length")
const channel =
input.webSocket && (options.enabled?.(parts.url) ?? true)
? yield* Effect.gen(function* () {
const create = yield* message(parts.jsonBody)
const base = driver(options, create.message)
return {
url: yield* WebSocketTransport.toWebSocketUrl(options.url?.(parts.url) ?? parts.url),
headers,
rotateAfterMs: options.rotateAfterMs,
driver: options.driver?.({ request: create.request, message: create.message, base }) ?? base,
}
})
: undefined
const channel = input.webSocket
? yield* Effect.gen(function* () {
const create = yield* message(parts.jsonBody)
const base = driver(options, create.message)
return {
url: yield* WebSocketTransport.toWebSocketUrl(parts.url),
headers,
rotateAfterMs: options.rotateAfterMs,
driver: options.driver?.({ request: create.request, message: create.message, base }) ?? base,
}
})
: undefined
return {
http: {
request: ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }),
+45 -86
View File
@@ -93,8 +93,6 @@ export const InputItem = Schema.Union([
Schema.Struct({ role: Schema.tag("developer"), content: Schema.String }),
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenResponsesInputContent) }),
Schema.Struct({
type: Schema.tag("message"),
id: Schema.optionalKey(Schema.String),
role: Schema.tag("assistant"),
content: Schema.Array(OpenResponsesOutputText),
phase: Schema.optionalKey(MessagePhase),
@@ -103,7 +101,6 @@ export const InputItem = Schema.Union([
OpenResponsesItemReference,
Schema.Struct({
type: Schema.tag("function_call"),
id: Schema.optionalKey(Schema.String),
call_id: Schema.String,
name: Schema.String,
arguments: Schema.String,
@@ -118,8 +115,6 @@ type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
type LoweredInputItem =
| OpenResponsesInputItem
| {
readonly type: "message"
readonly id?: string
readonly role: "assistant"
readonly content: ReadonlyArray<{ readonly type: "output_text"; readonly text: string }>
readonly phase?: MessagePhase | null
@@ -133,6 +128,8 @@ type OpenResponsesReasoningInput = {
summary: Array<{ type: "summary_text"; text: string }>
encrypted_content?: string | null
}
type OpenResponsesReasoningReplay = Omit<OpenResponsesReasoningInput, "id">
export const Tool = Schema.Struct({
type: Schema.tag("function"),
name: Schema.String,
@@ -301,20 +298,6 @@ export const Event = Schema.StructWithRest(
)
export type Event = Schema.Schema.Type<typeof Event>
const RefusalEvent = Schema.Union([
Schema.Struct({
type: Schema.tag("response.refusal.delta"),
item_id: Schema.String,
delta: Schema.String,
}),
Schema.Struct({
type: Schema.tag("response.refusal.done"),
item_id: Schema.String,
refusal: Schema.String,
}),
])
const isRefusalEvent = Schema.is(RefusalEvent)
export interface Extension {
readonly id: string
readonly name: string
@@ -380,42 +363,34 @@ export const lowerToolChoice = (protocolName: string, toolChoice: NonNullable<LL
tool: (toolName) => ({ type: "function" as const, name: toolName }),
})
const itemID = (providerMetadata: ProviderMetadata | undefined, providerMetadataKey: string) => {
const metadata = providerMetadata?.[providerMetadataKey]
return ProviderShared.isRecord(metadata) && typeof metadata.itemId === "string" && metadata.itemId.length > 0
? metadata.itemId
: undefined
}
const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenResponsesInputItem => {
const id = itemID(part.providerMetadata, providerMetadataKey)
return {
type: "function_call",
...(id ? { id } : {}),
call_id: part.id,
name: part.name,
arguments: ProviderShared.encodeJson(part.input),
}
}
const lowerToolCall = (part: ToolCallPart): OpenResponsesInputItem => ({
type: "function_call",
call_id: part.id,
name: part.name,
arguments: ProviderShared.encodeJson(part.input),
})
const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
const metadata = part.providerMetadata?.[providerMetadataKey]
const id = itemID(part.providerMetadata, providerMetadataKey)
if (!ProviderShared.isRecord(metadata) || !id) return undefined
if (!ProviderShared.isRecord(metadata) || typeof metadata.itemId !== "string" || metadata.itemId.length === 0)
return undefined
const encryptedContent =
typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null
? metadata.reasoningEncryptedContent
: undefined
return {
type: "reasoning",
id,
id: metadata.itemId,
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
encrypted_content: encryptedContent,
}
}
const hostedToolItemID = (part: ToolResultPart, providerMetadataKey: string) => {
return itemID(part.providerMetadata, providerMetadataKey)
const metadata = part.providerMetadata?.[providerMetadataKey]
return ProviderShared.isRecord(metadata) && typeof metadata.itemId === "string" && metadata.itemId.length > 0
? metadata.itemId
: undefined
}
const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
@@ -500,26 +475,24 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
if (message.role === "assistant") {
const content: TextPart[] = []
const reasoningItems: Record<string, OpenResponsesReasoningInput> = {}
const reasoningItems: Record<string, OpenResponsesReasoningReplay> = {}
const reasoningReferences = new Set<string>()
const hostedToolReferences = new Set<string>()
const flushText = () => {
if (content.length === 0) return
const groups = content.reduce<
Array<{ id: string | undefined; phase: MessagePhase | null | undefined; parts: TextPart[] }>
>((groups, part) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
const id = itemID(part.providerMetadata, providerMetadataKey)
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase, extension) : undefined
const group = groups.at(-1)
if (group && group.id === id && group.phase === phase) group.parts.push(part)
else groups.push({ id, phase, parts: [part] })
return groups
}, [])
const groups = content.reduce<Array<{ phase: MessagePhase | null | undefined; parts: TextPart[] }>>(
(groups, part) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase, extension) : undefined
const group = groups.at(-1)
if (group && group.phase === phase) group.parts.push(part)
else groups.push({ phase, parts: [part] })
return groups
},
[],
)
input.push(
...groups.map((group) => ({
type: "message" as const,
...(group.id === undefined ? {} : { id: group.id }),
role: "assistant" as const,
content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
...(group.phase === undefined ? {} : { phase: group.phase }),
@@ -548,14 +521,19 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
existing.encrypted_content = reasoning.encrypted_content
continue
}
reasoningItems[reasoning.id] = reasoning
input.push(reasoning)
const replay = {
type: reasoning.type,
summary: reasoning.summary,
encrypted_content: reasoning.encrypted_content,
}
reasoningItems[reasoning.id] = replay
input.push(replay)
continue
}
if (part.type === "tool-call") {
flushText()
if (part.providerExecuted === true) continue
input.push(lowerToolCall(part, providerMetadataKey))
input.push(lowerToolCall(part))
continue
}
if (part.type === "tool-result" && part.providerExecuted === true) {
@@ -735,7 +713,7 @@ const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepRe
if (!event.delta) return [state, NO_EVENTS]
const events: LLMEvent[] = []
const phase = state.messagePhases[id]
const metadata = providerMetadata(state, { itemId: id, ...(phase === undefined ? {} : { phase }) })
const metadata = phase === undefined ? undefined : providerMetadata(state, { phase })
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata)
return [{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta) }, events]
}
@@ -964,7 +942,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
state.lifecycle,
events,
item.id,
providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) }),
phase === undefined ? undefined : providerMetadata(state, { phase }),
),
messageItems,
messagePhases,
@@ -977,11 +955,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
const tools = state.tools[item.id]
? state.tools
: ToolStream.start(state.tools, item.id, {
id: item.call_id,
name: item.name,
providerMetadata: providerMetadata(state, { itemId: item.id }),
})
: ToolStream.start(state.tools, item.id, { id: item.call_id, name: item.name })
const result =
item.arguments === undefined
? yield* ToolStream.finish(state.id, tools, item.id)
@@ -1032,19 +1006,11 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
return [state, NO_EVENTS] satisfies StepResult
})
const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (state: ParserState, event: Event) {
// Some compatible providers omit output_item.done even after completing the response.
const pending =
event.type === "response.completed"
? yield* ToolStream.finishAll(state.id, state.tools)
: { tools: state.tools, events: NO_EVENTS }
const events: LLMEvent[] = [...pending.events]
const hasFunctionCall =
pending.events.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
state.hasFunctionCall
const onResponseFinish = (state: ParserState, event: Event): StepResult => {
const events: LLMEvent[] = []
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
reason: {
normalized: mapFinishReason(event, hasFunctionCall),
normalized: mapFinishReason(event, state.hasFunctionCall),
raw: event.response?.incomplete_details?.reason,
},
usage: mapUsage(event.response?.usage, state.providerMetadataKey),
@@ -1056,8 +1022,8 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
})
: undefined,
})
return [{ ...state, lifecycle, hasFunctionCall, tools: pending.tools }, events] satisfies StepResult
})
return [{ ...state, lifecycle }, events]
}
// Build a single human-readable message from whatever the provider supplied.
// When both code and message are present, prefix the code so consumers see
@@ -1099,14 +1065,6 @@ export const step = (state: ParserState, event: Event) => {
: onOutputTextDone(state, event, event.item_id),
)
}
if (event.type === "response.refusal.delta" || event.type === "response.refusal.done") {
if (!isRefusalEvent(event)) return ProviderShared.eventError(state.id, `${event.type} is malformed`)
return Effect.succeed(
event.type === "response.refusal.delta"
? onOutputTextDelta(state, event, event.item_id)
: onOutputTextDone(state, { ...event, text: event.refusal }, event.item_id),
)
}
if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_summary_text.delta") {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(onReasoningDelta(state, event, event.item_id))
@@ -1134,7 +1092,8 @@ export const step = (state: ParserState, event: Event) => {
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
return onOutputItemDone(state, event)
}
if (event.type === "response.completed" || event.type === "response.incomplete") return onResponseFinish(state, event)
if (event.type === "response.completed" || event.type === "response.incomplete")
return Effect.succeed(onResponseFinish(state, event))
if (event.type === "response.failed") return providerError(state, event, `${state.name} response failed`)
if (event.type === "error")
return decodeKnownErrorEvent(event).pipe(
+2 -14
View File
@@ -28,7 +28,7 @@ import { ToolSchemaProjection } from "./utils/tool-schema.js"
import { ToolStream } from "./utils/tool-stream.js"
const ADAPTER = "openai-chat"
const RESERVED_REASONING_FIELDS = new Set(["role", "content", "refusal", "tool_calls"])
const RESERVED_REASONING_FIELDS = new Set(["role", "content", "tool_calls"])
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
export const PATH = "/chat/completions"
@@ -194,7 +194,6 @@ type OpenAIChatToolCallDelta = Schema.Schema.Type<typeof OpenAIChatToolCallDelta
const OpenAIChatDelta = Schema.StructWithRest(
Schema.Struct({
content: optionalNull(Schema.String),
refusal: optionalNull(Schema.String),
reasoning_content: optionalNull(Schema.String),
reasoning: optionalNull(Schema.String),
reasoning_text: optionalNull(Schema.String),
@@ -710,7 +709,6 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
const reasoning = reasoningDelta(delta, state.reasoningField)
const hasLateContent =
Boolean(delta?.content) ||
Boolean(delta?.refusal) ||
reasoning !== undefined ||
(Array.isArray(delta?.reasoning_details) && delta.reasoning_details.length > 0) ||
toolDeltas.some((tool) => Boolean(tool.id) || Boolean(tool.function?.name) || Boolean(tool.function?.arguments))
@@ -730,7 +728,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
else if (
reasoningDetailsObserved &&
!lifecycle.reasoning.has("reasoning-0") &&
(Boolean(delta?.content) || Boolean(delta?.refusal) || toolDeltas.length > 0)
(Boolean(delta?.content) || toolDeltas.length > 0)
)
lifecycle = Lifecycle.reasoningStart(lifecycle, events, "reasoning-0", deltaMetadata)
const reasoningEmitted = state.reasoningEmitted || lifecycle.reasoning.has("reasoning-0")
@@ -745,16 +743,6 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
}
if (delta?.refusal) {
lifecycle = Lifecycle.reasoningEnd(
lifecycle,
events,
"reasoning-0",
reasoningMetadata(reasoningField, reasoningDetailsObserved ? state.reasoningDetails : undefined),
)
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.refusal)
}
// Compatible providers may omit indexes. Prefer durable identity, then use
// batch position for parallel deltas or the latest call for sparse chunks.
for (const [position, tool] of toolDeltas.entries()) {
@@ -11,7 +11,7 @@ import { optionalArray, ProviderShared } from "./shared.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { OpenAIImage } from "./utils/openai-image.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
import { OpenResponsesChannel, type Options } from "./open-responses-channel.js"
import { OpenResponsesChannel } from "./open-responses-channel.js"
import { OpenAIResponsesChannel } from "./openai-responses-channel.js"
const ADAPTER = "openai-responses"
@@ -42,8 +42,6 @@ const OpenAIResponsesToolChoice = Schema.Union([
const OpenAIResponsesInputItem = Schema.Union([
Schema.Struct({
type: Schema.tag("message"),
id: Schema.optionalKey(Schema.String),
role: Schema.tag("assistant"),
content: Schema.Array(Schema.Struct({ type: Schema.tag("output_text"), text: Schema.String })),
phase: Schema.optionalKey(Schema.NullOr(OpenResponses.MessagePhase)),
@@ -247,16 +245,12 @@ const endpoint = Endpoint.path<OpenAIResponsesBody>(PATH, { baseURL: DEFAULT_BAS
const auth = Auth.none
export const httpTransport = HttpTransport.sseJson.with<OpenAIResponsesBody>()
export const channelTransport = (options: Omit<Options, "driver">) =>
OpenResponsesChannel.transport<OpenAIResponsesBody>({
...options,
driver: (input) => OpenAIResponsesChannel.driver({ id: options.id, name: options.name, ...input }),
})
export const transport = channelTransport({
export const transport = OpenResponsesChannel.transport<OpenAIResponsesBody>({
id: ADAPTER,
name: NAME,
rotateAfterMs: WEBSOCKET_ROTATE_AFTER_MS,
headers: (headers) => Headers.set(headers, "openai-beta", headers["openai-beta"] ?? WEBSOCKET_PROTOCOL_HEADER),
driver: (input) => OpenAIResponsesChannel.driver({ id: ADAPTER, name: NAME, ...input }),
})
export const route = Route.make({
+4 -13
View File
@@ -197,28 +197,19 @@ export const errorText = (error: unknown) => {
/**
* `framing` step for Server-Sent Events. Decodes UTF-8, runs the SSE channel
* decoder, optionally filters named events, and drops empty / `[DONE]`
* keep-alive events so the protocol event schema sees one JSON string per
* element. The SSE channel emits a
* decoder, and drops empty / `[DONE]` keep-alive events so the protocol event
* schema sees one JSON string per element. The SSE channel emits a
* `Retry` control event on its error channel; we drop it here (we don't
* implement client-driven retries). Decoder failures become provider output
* errors so the public error channel stays `AIError`.
*/
export const sseFraming = (
bytes: Stream.Stream<Uint8Array, AIError>,
events?: ReadonlySet<string>,
): Stream.Stream<string, AIError> =>
export const sseFraming = (bytes: Stream.Stream<Uint8Array, AIError>): Stream.Stream<string, AIError> =>
bytes.pipe(
Stream.decodeText(),
Stream.pipeThroughChannel(Sse.decode()),
Stream.catchTag("Retry", () => Stream.empty),
Stream.catchTag("SseError", (error) => Stream.fail(eventError("sse", error.message))),
Stream.filter(
(event) =>
(events === undefined || events.has(event.event)) &&
event.data.length > 0 &&
(event.data !== "[DONE]" || (events !== undefined && event.event !== "message")),
),
Stream.filter((event) => event.data.length > 0 && event.data !== "[DONE]"),
Stream.map((event) => event.data),
)
+2 -8
View File
@@ -21,15 +21,9 @@ export const textStart = (state: State, events: LLMEvent[], id: string, provider
return { ...stepped, text: new Set([...stepped.text, id]) }
}
export const textDelta = (
state: State,
events: LLMEvent[],
id: string,
text: string,
providerMetadata?: ProviderMetadata,
): State => {
export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
const started = textStart(state, events, id)
events.push(LLMEvent.textDelta({ id, text, providerMetadata }))
events.push(LLMEvent.textDelta({ id, text }))
return started
}
-2
View File
@@ -74,7 +74,6 @@ const INVALID_REQUEST_CODES = new Set(["invalid_prompt", "invalid_request_error"
const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|too[_\s]?many[_\s]?requests/i
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
const CONTENT_POLICY_TEXT = /content[-_\s]?policy|content_filter|safety/i
const NETWORK_ERROR_TEXT = /network[-_\s]error/i
export interface ProviderFailure {
readonly message: string
@@ -128,7 +127,6 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
retryAfterMs: input.retryAfterMs,
rateLimit: input.rateLimit,
})
if (NETWORK_ERROR_TEXT.test(text)) return new ProviderInternalReason({ ...common, status: input.status })
if (codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable")))
return new ProviderInternalReason({
...common,
+5
View File
@@ -4,6 +4,11 @@ export interface Settings extends Readonly<Record<string, unknown>> {
readonly baseURL?: string
readonly headers?: Readonly<Record<string, string>>
readonly body?: Readonly<Record<string, unknown>>
readonly limits?: {
readonly context: number
readonly input?: number
readonly output: number
}
}
export interface Definition<
@@ -90,6 +90,7 @@ const config = (settings: Settings): Config => {
credentials: settings.credentials,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions,
region: settings.region,
}
@@ -62,6 +62,7 @@ export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, se
generation: settings.topP === undefined ? undefined : { topP: settings.topP },
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
region: settings.region,
}).model(modelID)
}
@@ -68,6 +68,7 @@ export const model: ProviderPackage.Definition<Settings, AnthropicMessages.Provi
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
provider: settings.provider,
providerOptions: settings.providerOptions,
}).model(modelID)
+1
View File
@@ -63,6 +63,7 @@ export const model: ProviderPackage.Definition<Settings, AnthropicMessages.Provi
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions,
}).model(modelID)
}
+1 -26
View File
@@ -1,4 +1,3 @@
import { Headers } from "effect/unstable/http"
import { Auth } from "../route/auth.js"
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
import type { Route as RouteDef, RouteDefaultsInput } from "../route/client.js"
@@ -11,7 +10,6 @@ import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-opt
export const id = ProviderID.make("azure")
const routeAuth = Auth.remove("authorization")
const RESPONSES_WEBSOCKET_ROTATE_AFTER_MS = 55 * 60 * 1000
// Azure needs the customer's resource URL; supply either `resourceName`
// (helper builds the URL) or `baseURL` directly.
@@ -42,30 +40,6 @@ const responsesRoute = OpenAIResponses.route.with({
id: "azure-openai-responses",
provider: id,
auth: routeAuth,
transport: OpenAIResponses.channelTransport({
id: "azure-openai-responses",
name: "Azure OpenAI Responses",
rotateAfterMs: RESPONSES_WEBSOCKET_ROTATE_AFTER_MS,
enabled: (value) => {
const url = new URL(value)
return (
url.protocol === "https:" &&
url.hostname.endsWith(".openai.azure.com") &&
url.pathname.endsWith("/openai/v1/responses") &&
url.searchParams.get("api-version") === "v1"
)
},
url: (value) => {
const url = new URL(value)
url.searchParams.delete("api-version")
return url.toString()
},
headers: (headers) => {
const apiKey = headers["api-key"]
if (!apiKey) return headers
return Headers.remove(Headers.set(headers, "authorization", `Bearer ${apiKey}`), "api-key")
},
}),
})
const chatRoute = OpenAIChat.route.with({
@@ -152,6 +126,7 @@ const config = (settings: Settings): Config => {
apiVersion: settings.apiVersion,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions,
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
useDeploymentBasedUrls: settings.useDeploymentBasedUrls,
@@ -75,6 +75,7 @@ export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsIn
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
location: settings.location,
project: settings.project,
providerOptions: settings.providerOptions,
@@ -4,6 +4,7 @@ import { AnthropicMessages } from "../protocols/anthropic-messages.js"
import { Auth } from "../route/auth.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Framing } from "../route/framing.js"
import { Protocol } from "../route/protocol.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { GoogleVertexShared } from "./google-vertex-shared.js"
@@ -13,7 +14,6 @@ export type AnthropicProviderOptionsInput = AnthropicMessages.ProviderOptionsInp
export type AnthropicThinkingInput = AnthropicMessages.ThinkingInput
const VERSION = "vertex-2023-10-16" as const
const HEADER_VERSION = "2023-06-01" as const
export const id = ProviderID.make("google-vertex")
@@ -57,8 +57,7 @@ const route = Route.make({
}),
endpoint: Endpoint.path(({ request }) => `/${request.model.id}:streamRawPredict`),
auth: Auth.none,
framing: AnthropicMessages.framing,
headers: () => ({ "anthropic-version": HEADER_VERSION }),
framing: Framing.sse,
})
export const routes = [route]
@@ -111,6 +110,7 @@ export const model: ProviderPackage.Definition<Settings, AnthropicMessages.Provi
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
location: settings.location,
project: settings.project,
providerOptions: settings.providerOptions,
@@ -80,6 +80,7 @@ export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOp
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
location: settings.location,
project: settings.project,
providerOptions: settings.providerOptions,
@@ -119,6 +119,7 @@ export const model: ProviderPackage.Definition<Settings, GeminiProviderOptionsIn
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
location: settings.location,
project: settings.project,
providerOptions: settings.providerOptions,
+1
View File
@@ -63,6 +63,7 @@ export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsI
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions,
}).model(modelID)
@@ -55,6 +55,7 @@ export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOp
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
provider: settings.provider,
providerOptions: settings.providerOptions,
}).model(modelID)
@@ -74,6 +74,7 @@ export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsIn
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
provider: settings.provider,
providerOptions: settings.providerOptions,
}).model(modelID)
+1
View File
@@ -124,6 +124,7 @@ const config = (settings: Settings): Config => {
baseURL: settings.baseURL,
headers: Object.keys(headers).length === 0 ? undefined : headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions,
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
}
+1
View File
@@ -200,5 +200,6 @@ export const model: ProviderPackage.Definition<Settings, OpenRouterProviderOptio
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions,
}).model(modelID)
+2 -7
View File
@@ -28,19 +28,13 @@ export interface Settings extends ProviderPackage.Settings {
export type { XAIImageOptions } from "../protocols/xai-images.js"
const RESPONSES_WEBSOCKET_ROTATE_AFTER_MS = 24 * 60 * 1000
const responsesRoute = Route.make({
id: "openai-responses",
provider: id,
providerMetadataKey: "xai",
protocol: OpenAIResponses.protocol,
endpoint: Endpoint.path("/responses", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
transport: OpenAIResponses.channelTransport({
id: "openai-responses",
name: "xAI Responses",
rotateAfterMs: RESPONSES_WEBSOCKET_ROTATE_AFTER_MS,
}),
transport: OpenAIResponses.httpTransport,
defaults: { providerOptions: { store: false } },
})
@@ -107,6 +101,7 @@ export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions,
}).model(modelID)
export const responses = provider.responses
+4
View File
@@ -16,6 +16,7 @@ import {
LLMRequest,
LLMResponse,
LanguageModel,
LanguageModelLimits,
LLMEvent,
InvalidProviderOutputReason,
ProviderID,
@@ -73,6 +74,7 @@ export type RouteRoutedLanguageModelInput = Omit<LanguageModel.Input, "route">
export interface RouteDefaults {
readonly headers?: Record<string, string>
readonly limits?: LanguageModelLimits
readonly generation?: GenerationOptions
readonly providerOptions?: ProviderOptions
readonly http?: HttpOptions
@@ -80,6 +82,7 @@ export interface RouteDefaults {
export interface RouteDefaultsInput {
readonly headers?: Record<string, string>
readonly limits?: LanguageModelLimits.Input
readonly generation?: GenerationOptions.Input
readonly providerOptions?: ProviderOptions
readonly http?: HttpOptions.Input
@@ -116,6 +119,7 @@ const mergeRouteDefaults = (base: RouteDefaults | undefined, patch: RouteDefault
...base,
...patch,
headers,
limits: patch.limits === undefined ? base?.limits : LanguageModelLimits.make(patch.limits),
generation: mergeGenerationOptions(generationOptions(base?.generation), generationOptions(patch.generation)),
providerOptions: mergeProviderOptions(base?.providerOptions, patch.providerOptions),
http: mergeHttpOptions(
-6
View File
@@ -24,10 +24,4 @@ export interface Definition<Frame> {
/** Server-Sent Events framing. Used by every JSON-streaming HTTP provider. */
export const sse: Definition<string> = { id: "sse", frame: ProviderShared.sseFraming }
/** SSE framing restricted to protocol-recognized event names. */
export const sseEvents = (events: ReadonlySet<string>): Definition<string> => ({
id: "sse",
frame: (bytes) => ProviderShared.sseFraming(bytes, events),
})
export * as Framing from "./framing.js"
+17
View File
@@ -114,7 +114,22 @@ export const mergeGenerationOptions = (...items: ReadonlyArray<GenerationOptions
return Object.values(result).some((value) => value !== undefined) ? result : undefined
}
export class LanguageModelLimits extends Schema.Class<LanguageModelLimits>("LLM.LanguageModelLimits")({
context: Schema.optional(Schema.Number),
input: Schema.optional(Schema.Number),
output: Schema.optional(Schema.Number),
}) {}
export namespace LanguageModelLimits {
export type Input = LanguageModelLimits | ConstructorParameters<typeof LanguageModelLimits>[0]
/** Normalize model limit input into the canonical `LanguageModelLimits` class. */
export const make = (input: Input | undefined) =>
input instanceof LanguageModelLimits ? input : new LanguageModelLimits(input ?? {})
}
export class LanguageModelDefaults extends Schema.Class<LanguageModelDefaults>("LLM.LanguageModelDefaults")({
limits: Schema.optional(LanguageModelLimits),
generation: Schema.optional(GenerationOptions),
providerOptions: Schema.optional(ProviderOptions),
http: Schema.optional(HttpOptions),
@@ -124,6 +139,7 @@ export namespace LanguageModelDefaults {
export type Input =
| LanguageModelDefaults
| {
readonly limits?: LanguageModelLimits.Input
readonly generation?: GenerationOptions.Input
readonly providerOptions?: ProviderOptions
readonly http?: HttpOptions.Input
@@ -133,6 +149,7 @@ export namespace LanguageModelDefaults {
export const make = (input: Input) => {
if (input instanceof LanguageModelDefaults) return input
return new LanguageModelDefaults({
limits: input.limits === undefined ? undefined : LanguageModelLimits.make(input.limits),
generation: input.generation === undefined ? undefined : GenerationOptions.make(input.generation),
providerOptions: input.providerOptions,
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
+6 -5
View File
@@ -270,20 +270,21 @@ describe("request option precedence", () => {
),
)
it.effect("uses the Anthropic default before call maxTokens", () =>
it.effect("uses model output limits after route limits and before call maxTokens", () =>
Effect.gen(function* () {
const route = AnthropicMessages.route.with({
endpoint: { baseURL: "https://api.anthropic.test/v1/" },
auth: Auth.header("x-api-key", "test"),
limits: { output: 128 },
})
const model = route.model({ id: "claude-sonnet-4-5" })
const model = route.model({ id: "claude-sonnet-4-5", defaults: { limits: { output: 64 } } })
const withoutMaxTokens = yield* compileRequest(LLM.request({ model, prompt: "Say hello.", cache: "none" }))
const withMaxTokens = yield* compileRequest(
LLM.request({ model, prompt: "Say hello.", cache: "none", generation: { maxTokens: 8_000 } }),
LLM.request({ model, prompt: "Say hello.", cache: "none", generation: { maxTokens: 32 } }),
)
expect(withoutMaxTokens.body.max_tokens).toBe(32_000)
expect(withMaxTokens.body.max_tokens).toBe(8_000)
expect(withoutMaxTokens.body.max_tokens).toBe(64)
expect(withMaxTokens.body.max_tokens).toBe(32)
}),
)
})
File diff suppressed because one or more lines are too long
@@ -1,100 +0,0 @@
{
"version": 1,
"metadata": {
"provider": "openai",
"protocol": "openai-responses",
"transport": "websocket",
"model": "gpt-5.5",
"tags": [
"prefix:openai-responses-websocket",
"provider:openai",
"protocol:openai-responses",
"transport:websocket",
"tool",
"continuation"
],
"name": "openai-responses-websocket/continues-a-tool-call-over-one-socket",
"recordedAt": "2026-08-20T00:00:00.000Z"
},
"interactions": [
{
"transport": "websocket",
"connection": {
"sequence": 0,
"url": "wss://api.openai.com/v1/responses",
"protocols": [],
"close": {
"code": 1000,
"reason": ""
}
},
"events": [
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Call get_weather once, then reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":50,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_tool_1\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"function_call\",\"id\":\"fc_ws_weather\",\"call_id\":\"call_ws_weather\",\"name\":\"get_weather\",\"arguments\":\"\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.function_call_arguments.delta\",\"item_id\":\"fc_ws_weather\",\"delta\":\"{\\\"city\\\":\\\"Paris\\\"}\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"function_call\",\"id\":\"fc_ws_weather\",\"call_id\":\"call_ws_weather\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_tool_1\"}}"
},
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"function_call_output\",\"call_id\":\"call_ws_weather\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":50,\"previous_response_id\":\"resp_ws_tool_1\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_tool_2\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_tool_2\",\"role\":\"assistant\",\"content\":[]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_tool_2\",\"delta\":\"Paris is sunny.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_tool_2\",\"text\":\"Paris is sunny.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_tool_2\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Paris is sunny.\"}]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_tool_2\"}}"
}
]
}
]
}
@@ -1,119 +0,0 @@
{
"version": 1,
"metadata": {
"provider": "openai",
"protocol": "openai-responses",
"transport": "websocket",
"model": "gpt-5.5",
"tags": [
"prefix:openai-responses-websocket",
"provider:openai",
"protocol:openai-responses",
"transport:websocket",
"reconnect",
"full-context"
],
"name": "openai-responses-websocket/reconstructs-full-context-after-reconnect",
"recordedAt": "2026-08-20T00:00:00.000Z"
},
"interactions": [
{
"transport": "websocket",
"connection": {
"sequence": 0,
"url": "wss://api.openai.com/v1/responses",
"protocols": [],
"close": {
"code": 1000,
"reason": ""
}
},
"events": [
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_reconnect_1\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"content\":[]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_reconnect_1\",\"delta\":\"Alpha.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_reconnect_1\",\"text\":\"Alpha.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Alpha.\"}]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_reconnect_1\"}}"
}
]
},
{
"transport": "websocket",
"connection": {
"sequence": 1,
"url": "wss://api.openai.com/v1/responses",
"protocols": [],
"close": {
"code": 1000,
"reason": ""
}
},
"events": [
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Alpha.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Beta.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_reconnect_2\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_reconnect_2\",\"role\":\"assistant\",\"content\":[]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_reconnect_2\",\"delta\":\"Beta.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_reconnect_2\",\"text\":\"Beta.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_reconnect_2\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Beta.\"}]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_reconnect_2\"}}"
}
]
}
]
}
@@ -1,129 +0,0 @@
{
"version": 1,
"metadata": {
"provider": "openai",
"protocol": "openai-responses",
"transport": "websocket",
"model": "gpt-5.5",
"tags": [
"prefix:openai-responses-websocket",
"provider:openai",
"protocol:openai-responses",
"transport:websocket",
"continuation",
"recovery"
],
"name": "openai-responses-websocket/recovers-from-explicit-continuation-rejection",
"recordedAt": "2026-08-20T00:00:00.000Z"
},
"interactions": [
{
"transport": "websocket",
"connection": {
"sequence": 0,
"url": "wss://api.openai.com/v1/responses",
"protocols": [],
"close": {
"code": 1000,
"reason": ""
}
},
"events": [
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_rejection_1\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"content\":[]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_rejection_1\",\"delta\":\"Ready.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_rejection_1\",\"text\":\"Ready.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Ready.\"}]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_rejection_1\"}}"
}
]
},
{
"transport": "websocket",
"connection": {
"sequence": 1,
"url": "wss://api.openai.com/v1/responses",
"protocols": [],
"close": {
"code": 1000,
"reason": ""
}
},
"events": [
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"store\":false,\"max_output_tokens\":30,\"previous_response_id\":\"resp_ws_rejection_1\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"error\",\"error\":{\"code\":\"previous_response_not_found\",\"message\":\"Previous response not found\"}}"
},
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Ready.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_rejection_2\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_rejection_2\",\"role\":\"assistant\",\"content\":[]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_rejection_2\",\"delta\":\"Recovered.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_rejection_2\",\"text\":\"Recovered.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_rejection_2\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Recovered.\"}]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_rejection_2\"}}"
}
]
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-3
View File
@@ -10,9 +10,6 @@ export const sseEvents = (...chunks: ReadonlyArray<unknown>): string =>
const formatChunk = (chunk: unknown) => `data: ${typeof chunk === "string" ? chunk : JSON.stringify(chunk)}\n\n`
export const sseNamedEvent = (event: string, data: unknown): string =>
`event: ${event}\ndata: ${typeof data === "string" ? data : JSON.stringify(data)}`
/**
* Build an SSE body from already-serialized strings (used when the chunk shape
* itself is part of what's being tested, e.g. malformed chunks).
+2
View File
@@ -121,6 +121,7 @@ describe("llm constructors", () => {
const model = chatRoute.model({
id: "kimi-k2",
defaults: {
limits: { context: 128_000, output: 8_192 },
generation: { maxTokens: 1_024, stop: ["END"] },
providerOptions: { parallelToolCalls: false },
http: { body: { extra_body: true } },
@@ -129,6 +130,7 @@ describe("llm constructors", () => {
})
const request = LLM.request({ model, prompt: "Say hello." })
expect(request.model.defaults?.limits).toEqual({ context: 128_000, output: 8_192 })
expect(request.model.defaults?.generation).toEqual({ maxTokens: 1_024, stop: ["END"] })
expect(request.model.defaults?.providerOptions).toEqual({ parallelToolCalls: false })
expect(request.model.defaults?.http).toEqual({ body: { extra_body: true } })
-8
View File
@@ -82,14 +82,6 @@ describe("provider error classification", () => {
])
})
test("classifies network error text as provider internal", () => {
expect(
["network error", "network-error", "network_error"].map(
(message) => classifyProviderFailure({ message })._tag,
),
).toEqual(["ProviderInternal", "ProviderInternal", "ProviderInternal"])
})
test("classifies nested provider codes when a top-level code is also present", () => {
expect(
[
+14
View File
@@ -43,6 +43,7 @@ describe("provider package entrypoints", () => {
baseURL: "https://provider.example.test/v1",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
limits: { context: 200_000, output: 64_000 },
}
const openrouter = OpenRouter.model("anthropic/claude-sonnet-4", {
...settings,
@@ -57,6 +58,7 @@ describe("provider package entrypoints", () => {
expect(selected.route.endpoint.baseURL).toBe(settings.baseURL)
expect(selected.route.defaults.headers).toEqual(settings.headers)
expect(selected.route.defaults.http?.body).toEqual(settings.body)
expect(selected.route.defaults.limits).toEqual(settings.limits)
}
expect(openrouter.route.defaults.providerOptions).toEqual({ usage: true })
expect(xai.route.defaults.providerOptions).toMatchObject({ reasoningEffort: "high", store: false })
@@ -68,12 +70,14 @@ describe("provider package entrypoints", () => {
baseURL: "https://api.openai.test/v1",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
limits: { context: 200_000, output: 64_000 },
unrelatedInheritedSetting: true,
})
expect(selected.route.id).toBe("openai-responses")
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
})
test("maps OpenAI-compatible Responses settings onto the executable model", async () => {
@@ -84,6 +88,7 @@ describe("provider package entrypoints", () => {
provider: "example",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
limits: { context: 200_000, output: 64_000 },
providerOptions: { reasoningEffort: "low", store: true },
})
@@ -95,6 +100,7 @@ describe("provider package entrypoints", () => {
})
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
expect(selected.route.defaults.providerOptions).toEqual({ reasoningEffort: "low", store: true })
})
@@ -106,6 +112,7 @@ describe("provider package entrypoints", () => {
provider: "example",
headers: { "x-application": "opencode" },
body: { metadata: { user_id: "user_1" } },
limits: { context: 200_000, output: 64_000 },
providerOptions: { effort: "low" },
})
@@ -117,6 +124,7 @@ describe("provider package entrypoints", () => {
})
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(selected.route.defaults.http?.body).toEqual({ metadata: { user_id: "user_1" } })
expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
expect(selected.route.defaults.providerOptions).toEqual({ effort: "low" })
})
@@ -177,6 +185,7 @@ describe("provider package entrypoints", () => {
resourceName: "opencode-test",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
limits: { context: 200_000, output: 64_000 },
}
const responses = AzureResponses.model("deployment", settings)
@@ -187,6 +196,7 @@ describe("provider package entrypoints", () => {
expect(responses.route.endpoint.baseURL).toBe("https://opencode-test.openai.azure.com/openai/v1")
expect(responses.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(responses.route.defaults.http?.body).toEqual({ service_tier: "priority" })
expect(responses.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
expect(chat.route.id).toBe("azure-openai-chat")
})
@@ -218,6 +228,7 @@ describe("provider package entrypoints", () => {
baseURL: "https://generativelanguage.test/v1beta",
headers: { "x-application": "opencode" },
body: { safetySettings: [] },
limits: { context: 1_000_000, output: 65_536 },
providerOptions: { thinkingConfig: { thinkingBudget: 1_024 } },
})
@@ -225,6 +236,7 @@ describe("provider package entrypoints", () => {
expect(selected.route.endpoint.baseURL).toBe("https://generativelanguage.test/v1beta")
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(selected.route.defaults.http?.body).toEqual({ safetySettings: [] })
expect(selected.route.defaults.limits).toEqual({ context: 1_000_000, output: 65_536 })
expect(selected.route.defaults.providerOptions).toEqual({ thinkingConfig: { thinkingBudget: 1_024 } })
})
@@ -238,6 +250,7 @@ describe("provider package entrypoints", () => {
apiKey: "fixture",
headers: { "x-application": "opencode" },
body: { safetySettings: [] },
limits: { context: 1_000_000, output: 65_536 },
})
const messages = GoogleVertexMessages.model("claude-sonnet-4-6", {
accessToken: "fixture",
@@ -261,6 +274,7 @@ describe("provider package entrypoints", () => {
expect(gemini.route.endpoint.baseURL).toBe("https://aiplatform.googleapis.com/v1/publishers/google")
expect(gemini.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(gemini.route.defaults.http?.body).toEqual({ safetySettings: [] })
expect(gemini.route.defaults.limits).toEqual({ context: 1_000_000, output: 65_536 })
expect(
GoogleVertex.model("gemini-3.5-flash", {
accessToken: "fixture",
@@ -8,7 +8,7 @@ import * as AnthropicMessages from "../../src/protocols/anthropic-messages.js"
import { continuationRequest, nativeAnthropicMessagesContinuation } from "../continuation-scenarios.js"
import { it } from "../lib/effect.js"
import { dynamicResponse, fixedResponse } from "../lib/http.js"
import { sseEvents, sseNamedEvent, sseRaw } from "../lib/sse.js"
import { sseEvents } from "../lib/sse.js"
const model = AnthropicMessages.route
.with({ endpoint: { baseURL: "https://api.anthropic.test/v1/" }, auth: Auth.header("x-api-key", "test") })
@@ -322,34 +322,11 @@ describe("Anthropic Messages route", () => {
{ role: "user", content: [{ type: "tool_result", tool_use_id: "call_1", content: '{"forecast":"sunny"}' }] },
],
stream: true,
max_tokens: 32_000,
max_tokens: 4096,
})
}),
)
it.effect("scrubs outbound tool call IDs without truncating them", () =>
Effect.gen(function* () {
const id = `functions.lookup:1|${"x".repeat(64)}`
const scrubbed = `functions_lookup_1_${"x".repeat(64)}`
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([ToolCallPart.make({ id, name: "lookup", input: {} })]),
Message.tool({ id, name: "lookup", result: "done" }),
],
cache: "none",
}),
)
expect(prepared.body.messages).toMatchObject([
{ role: "assistant", content: [{ type: "tool_use", id: scrubbed, name: "lookup", input: {} }] },
{ role: "user", content: [{ type: "tool_result", tool_use_id: scrubbed }] },
])
expect(scrubbed.length).toBeGreaterThan(64)
}),
)
it.effect("batches parallel tool results into one Anthropic user message", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -663,60 +640,7 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("ignores unknown named SSE events", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseRaw(
sseNamedEvent("message_start", {
type: "message_start",
message: { usage: { input_tokens: 5 } },
}),
sseNamedEvent("proxy.stats", "not json"),
sseNamedEvent("content_block_start", {
type: "content_block_start",
index: 0,
content_block: { type: "text", text: "" },
}),
sseNamedEvent("content_block_delta", {
type: "content_block_delta",
index: 0,
delta: { type: "text_delta", text: "Hello" },
}),
sseNamedEvent("content_block_stop", { type: "content_block_stop", index: 0 }),
sseNamedEvent("message_delta", {
type: "message_delta",
delta: { stop_reason: "end_turn" },
usage: { output_tokens: 1 },
}),
sseNamedEvent("message_stop", { type: "message_stop" }),
sseNamedEvent("proxy.done", "still not json"),
),
),
),
)
expect(response.message.content).toEqual([{ type: "text", text: "Hello" }])
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
}),
)
it.effect("rejects malformed recognized SSE events", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseRaw(sseNamedEvent("message_start", "[DONE]")))),
Effect.flip,
)
expect(error.reason).toMatchObject({
_tag: "InvalidProviderOutput",
message: "Invalid anthropic/anthropic-messages stream event",
})
}),
)
it.effect("maps nullable input tokens and preserves unknown Anthropic usage fields", () =>
it.effect("maps thinking tokens and preserves unknown Anthropic usage fields", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
@@ -739,7 +663,6 @@ describe("Anthropic Messages route", () => {
type: "message_delta",
delta: { stop_reason: "end_turn" },
usage: {
input_tokens: null,
output_tokens: 8,
server_tool_use: { web_search_requests: 2, terminal_counter: 3 },
output_tokens_details: { terminal_detail: "preserved" },
@@ -759,7 +682,7 @@ describe("Anthropic Messages route", () => {
totalTokens: 15,
providerMetadata: {
anthropic: {
input_tokens: null,
input_tokens: 5,
cache_read_input_tokens: 2,
service_tier: "standard",
cache_creation: { ephemeral_5m_input_tokens: 1 },
@@ -1416,14 +1339,14 @@ describe("Anthropic Messages route", () => {
Message.assistant([
{
type: "tool-call",
id: "srvtoolu.abc",
id: "srvtoolu_abc",
name: "web_search",
input: { query: "effect 4" },
providerExecuted: true,
},
{
type: "tool-result",
id: "srvtoolu.abc",
id: "srvtoolu_abc",
name: "web_search",
result: { type: "json", value: [{ url: "https://example.com" }] },
providerExecuted: true,
+5 -303
View File
@@ -139,95 +139,6 @@ describe("Gemini route", () => {
}),
)
it.effect("keeps system updates separate from function responses", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
Message.tool({ id: "call_1", name: "lookup", result: "done", resultType: "text" }),
Message.system("Update."),
Message.system("Later update."),
],
}),
)
expect(prepared.body.contents).toEqual([
{
role: "model",
parts: [{ functionCall: { id: undefined, name: "lookup", args: { query: "weather" } } }],
},
{
role: "user",
parts: [
{
functionResponse: {
id: undefined,
name: "lookup",
response: { name: "lookup", content: "done" },
},
},
],
},
{
role: "user",
parts: [
{ text: "<system-update>\nUpdate.\n</system-update>" },
{ text: "<system-update>\nLater update.\n</system-update>" },
],
},
])
}),
)
it.effect("merges parallel tool results into one function-response turn", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([
ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } }),
ToolCallPart.make({ id: "call_2", name: "lookup", input: { query: "time" } }),
]),
Message.tool({ id: "call_1", name: "lookup", result: "sunny", resultType: "text" }),
Message.tool({ id: "call_2", name: "lookup", result: "noon", resultType: "text" }),
],
}),
)
expect(prepared.body.contents).toEqual([
{
role: "model",
parts: [
{ functionCall: { id: undefined, name: "lookup", args: { query: "weather" } } },
{ functionCall: { id: undefined, name: "lookup", args: { query: "time" } } },
],
},
{
role: "user",
parts: [
{
functionResponse: {
id: undefined,
name: "lookup",
response: { name: "lookup", content: "sunny" },
},
},
{
functionResponse: {
id: undefined,
name: "lookup",
response: { name: "lookup", content: "noon" },
},
},
],
},
])
}),
)
it.effect("prepares multimodal user input and tool history", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -328,18 +239,14 @@ describe("Gemini route", () => {
functionResponse: {
name: "read",
response: { name: "read", content: "Image read successfully" },
parts: [
{ inlineData: { mimeType: "image/png", data: "AAECAw==" } },
{ inlineData: { mimeType: "application/pdf", data: "JVBERi0xLjQ=" } },
],
},
},
],
},
{
role: "user",
parts: [
{ text: "Attached media from tool result:" },
{ inlineData: { mimeType: "image/png", data: "AAECAw==" } },
{ inlineData: { mimeType: "application/pdf", data: "JVBERi0xLjQ=" } },
],
},
])
expect(JSON.stringify(prepared.body.contents)).not.toContain('"content":"AAECAw=="')
}),
@@ -372,161 +279,11 @@ describe("Gemini route", () => {
functionResponse: {
name: "read",
response: { name: "read", content: "" },
parts: [{ inlineData: { mimeType: "image/jpeg", data: "/9j/" } }],
},
},
],
},
{
role: "user",
parts: [
{ text: "Attached media from tool result:" },
{ inlineData: { mimeType: "image/jpeg", data: "/9j/" } },
],
},
])
}),
)
it.effect("nests media inside function responses for gemini 3", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: gemini3,
messages: [
Message.assistant([
ToolCallPart.make({
id: "call_image",
name: "read",
input: { path: "pixel.png" },
providerMetadata: { google: { thoughtSignature: "sig_1" } },
}),
]),
Message.tool({
id: "call_image",
name: "read",
result: {
type: "content",
value: [
{ type: "text", text: "Image read successfully" },
{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png", name: "pixel.png" },
],
},
}),
],
}),
)
expect(prepared.body.contents).toEqual([
{
role: "model",
parts: [{ functionCall: { name: "read", args: { path: "pixel.png" } }, thoughtSignature: "sig_1" }],
},
{
role: "user",
parts: [
{
functionResponse: {
name: "read",
response: { name: "read", content: "Image read successfully" },
parts: [{ inlineData: { mimeType: "image/png", data: "AAECAw==" } }],
},
},
],
},
])
}),
)
it.effect("flushes pending media before system update text", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "shot", input: {} })]),
Message.tool({
id: "call_1",
name: "shot",
result: {
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,AAEC", mime: "image/png" }],
},
}),
Message.system("Update."),
],
}),
)
expect(prepared.body.contents).toEqual([
{ role: "model", parts: [{ functionCall: { name: "shot", args: {} } }] },
{
role: "user",
parts: [{ functionResponse: { name: "shot", response: { name: "shot", content: "" } } }],
},
{
role: "user",
parts: [
{ text: "Attached media from tool result:" },
{ inlineData: { mimeType: "image/png", data: "AAEC" } },
{ text: "<system-update>\nUpdate.\n</system-update>" },
],
},
])
}),
)
it.effect("collects legacy tool media into one turn after merged responses", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([
ToolCallPart.make({ id: "call_1", name: "shot", input: {} }),
ToolCallPart.make({ id: "call_2", name: "shot", input: {} }),
]),
Message.tool({
id: "call_1",
name: "shot",
result: {
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,AAEC", mime: "image/png" }],
},
}),
Message.tool({
id: "call_2",
name: "shot",
result: {
type: "content",
value: [{ type: "text", text: "no image here" }],
},
}),
],
}),
)
expect(prepared.body.contents).toEqual([
{
role: "model",
parts: [
{ functionCall: { name: "shot", args: {} } },
{ functionCall: { name: "shot", args: {} } },
],
},
{
role: "user",
parts: [
{ functionResponse: { name: "shot", response: { name: "shot", content: "" } } },
{ functionResponse: { name: "shot", response: { name: "shot", content: "no image here" } } },
],
},
{
role: "user",
parts: [
{ text: "Attached media from tool result:" },
{ inlineData: { mimeType: "image/png", data: "AAEC" } },
],
},
])
}),
)
@@ -905,61 +662,6 @@ describe("Gemini route", () => {
}),
)
it.effect("preserves thoughtSignature on visible text parts", () =>
Effect.gen(function* () {
const body = sseEvents({
candidates: [
{
content: { role: "model", parts: [{ text: "All done.", thoughtSignature: "text_sig" }] },
finishReason: "STOP",
},
],
})
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
const delta = response.events.find((event) => event.type === "text-delta")
expect(delta).toMatchObject({
id: "text-0",
text: "All done.",
providerMetadata: { google: { thoughtSignature: "text_sig" } },
})
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [Message.assistant([{ type: "text", text: "All done.", providerMetadata: delta?.providerMetadata }])],
}),
)
expect(prepared.body.contents).toEqual([
{ role: "model", parts: [{ text: "All done.", thoughtSignature: "text_sig" }] },
])
}),
)
it.effect("flushes a trailing empty signed text part at block close", () =>
Effect.gen(function* () {
const body = sseEvents({
candidates: [
{
content: {
role: "model",
parts: [{ text: "Working." }, { text: "", thoughtSignature: "tail_sig" }],
},
finishReason: "STOP",
},
],
})
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
const delta = response.events.find((event) => event.type === "text-delta")
const end = response.events.find((event) => event.type === "text-end")
expect(delta).toMatchObject({ id: "text-0", text: "Working.", providerMetadata: undefined })
expect(end).toMatchObject({
id: "text-0",
providerMetadata: { google: { thoughtSignature: "tail_sig" } },
})
}),
)
it.effect("replays unsigned Gemini 3 tool calls with the validator bypass sentinel", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -96,7 +96,7 @@ describe("Google Vertex providers", () => {
"https://aiplatform.eu.rep.googleapis.com/v1/projects/vertex-project/locations/eu/publishers/anthropic/models/claude-sonnet-4-6:streamRawPredict",
)
expect(request.headers.get("authorization")).toBe("Bearer vertex-token")
expect(request.headers.get("anthropic-version")).toBe("2023-06-01")
expect(request.headers.get("anthropic-version")).toBeNull()
const body = yield* Effect.promise(() => request.json())
expect(body).toMatchObject({
anthropic_version: "vertex-2023-10-16",
@@ -664,74 +664,6 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("preserves streamed refusals as ordinary assistant text", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
deltaChunk({ role: "assistant", refusal: "I can't" }),
deltaChunk({ refusal: " help with that." }),
deltaChunk({}, "stop"),
),
),
),
)
expect(response.text).toBe("I can't help with that.")
expect(response.finishReason).toEqual({ normalized: "stop", raw: "stop" })
expect(response.message.content).toEqual([{ type: "text", text: "I can't help with that." }])
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(replay.body.messages).toEqual([{ role: "assistant", content: "I can't help with that." }])
}),
)
it.effect("orders metadata-only reasoning before refusal output", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning_details: [] } }] },
deltaChunk({ refusal: "I can't help with that." }),
deltaChunk({}, "stop"),
),
),
),
)
expect(response.message.content).toEqual([
{ type: "reasoning", text: "", providerMetadata: { openai: { reasoningDetails: [] } } },
{
type: "text",
text: "I can't help with that.",
},
])
}),
)
it.effect("joins content and refusal deltas into ordinary assistant text", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
deltaChunk({ refusal: "No." }),
deltaChunk({ content: " Alternative." }),
deltaChunk({ refusal: " Still no." }),
deltaChunk({}, "stop"),
),
),
),
)
expect(response.text).toBe("No. Alternative. Still no.")
expect(response.events.filter(LLMEvent.is.textStart).map((event) => event.id)).toEqual(["text-0"])
expect(response.events.filter(LLMEvent.is.textEnd).map((event) => event.id)).toEqual(["text-0"])
}),
)
it.effect("parses and replays OpenAI-compatible reasoning fields", () =>
Effect.gen(function* () {
const fields = ["reasoning_content", "reasoning", "reasoning_text"] as const
@@ -73,7 +73,7 @@ describe("Open Responses-compatible route", () => {
expect(prepared.body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
{ role: "developer", content: "Operator update." },
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "After." }] },
{ role: "assistant", content: [{ type: "output_text", text: "After." }] },
])
}),
)
@@ -113,67 +113,11 @@ describe("Open Responses-compatible route", () => {
)
expect(prepared.body).toMatchObject({
input: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: "Unclassified." }] }],
input: [{ role: "assistant", content: [{ type: "output_text", text: "Unclassified." }] }],
})
}),
)
it.effect("preserves standard refusal content as ordinary assistant text", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
provider: "example",
}).model("example-model")
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Unsafe request" })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
output_index: 0,
item: { type: "message", id: "msg_refusal", content: [] },
},
{
type: "response.refusal.done",
item_id: "msg_refusal",
refusal: "I can't help with that.",
},
{
type: "response.output_item.done",
output_index: 0,
item: {
type: "message",
id: "msg_refusal",
content: [{ type: "refusal", refusal: "I can't help with that." }],
},
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.message.content).toEqual([
{
type: "text",
text: "I can't help with that.",
providerMetadata: { openresponses: { itemId: "msg_refusal" } },
},
])
const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(prepared.body.input).toEqual([
{
type: "message",
id: "msg_refusal",
role: "assistant",
content: [{ type: "output_text", text: "I can't help with that." }],
},
])
}),
)
it.effect("reads standard Open Responses options", () =>
Effect.gen(function* () {
const model = configure({
@@ -1,216 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect, Stream } from "effect"
import { Socket } from "effect/unstable/socket"
import { LLM, LLMRequest, Message, ToolRuntime } from "../../src/index.js"
import {
LLMClient,
WebSocketTransport,
type ChannelCheckpoint,
type ChannelObservation,
type WebSocketChannelExchange,
type WebSocketChannelExecutor,
type WebSocketConnection,
} from "../../src/route.js"
import { configure } from "../../src/providers/openai.js"
import { decodeJson } from "../../src/protocols/shared.js"
import { weatherRuntimeTool, weatherTool, weatherToolName } from "../recorded-scenarios.js"
import { recordedTests } from "../recorded-test.js"
const model = configure({ apiKey: process.env.OPENAI_API_KEY ?? "fixture" }).responses("gpt-5.5")
const recorded = recordedTests({
prefix: "openai-responses-websocket",
provider: "openai",
protocol: "openai-responses",
requires: ["OPENAI_API_KEY"],
tags: ["transport:websocket"],
metadata: { transport: "websocket", model: model.id },
})
const observationFrame = (observation: ChannelObservation) => {
if (observation.type === "frame" || observation.type === "completed" || observation.type === "incomplete")
return Effect.succeed(observation.frame)
return Effect.fail(observation.error)
}
const terminal = (observation: ChannelObservation) => observation.type !== "frame"
// This deliberately models only sequential test traffic. Core owns production connection pooling and recovery.
const makeChannel = Effect.gen(function* () {
const constructor = yield* Socket.WebSocketConstructor
let connection: WebSocketConnection | undefined
let checkpoint: ChannelCheckpoint | undefined
let pending: ChannelCheckpoint | undefined
let opens = 0
const sent: unknown[] = []
const close = Effect.suspend(() => {
const current = connection
connection = undefined
return current ? current.close : Effect.void
})
yield* Effect.addFinalizer(() => close)
const executor: WebSocketChannelExecutor = {
execute: (exchange: WebSocketChannelExchange) =>
Effect.gen(function* () {
if (!connection) {
connection = yield* WebSocketTransport.open(exchange.connect).pipe(
Effect.provideService(Socket.WebSocketConstructor, constructor),
)
opens += 1
}
const current = connection
const create = yield* exchange.driver.create(checkpoint)
if (create.mode === "full") checkpoint = undefined
pending = undefined
sent.push(decodeJson(create.message))
yield* current.sendText(create.message)
const decoder = new TextDecoder()
return {
frames: current.messages.pipe(
Stream.map((message) => WebSocketTransport.messageText(message, decoder)),
Stream.mapEffect((frame) => exchange.driver.observe(create, frame)),
Stream.tap((observation) =>
Effect.sync(() => {
if (!terminal(observation)) return
pending = observation.type === "completed" ? observation.checkpoint : undefined
if (observation.type !== "completed") checkpoint = undefined
}),
),
Stream.takeUntil(terminal),
Stream.mapEffect(observationFrame),
),
complete: Effect.sync(() => {
checkpoint = pending
pending = undefined
}),
}
}),
}
return {
executor,
sent,
opens: () => opens,
reconnect: (preserveCheckpoint = false) =>
close.pipe(
Effect.andThen(
Effect.sync(() => {
pending = undefined
if (!preserveCheckpoint) checkpoint = undefined
}),
),
),
}
})
describe("OpenAI Responses WebSocket recorded", () => {
recorded.effect.with("continues a tool call over one socket", { tags: ["tool", "continuation"] }, () =>
Effect.gen(function* () {
const channel = yield* makeChannel
const request = LLM.request({
id: "recorded_openai_responses_websocket_tool",
model,
system: "Call get_weather once, then reply exactly: Paris is sunny.",
prompt: "What is the weather in Paris?",
tools: [weatherTool],
generation: { maxTokens: 50 },
cache: "none",
})
const first = yield* LLMClient.generate(request, { webSocket: channel.executor })
const call = first.toolCalls[0]
if (!call) yield* Effect.die("Expected get_weather tool call")
const result = yield* ToolRuntime.dispatch({ [weatherToolName]: weatherRuntimeTool }, call)
const second = yield* LLMClient.generate(
LLMRequest.update(request, {
messages: [
...request.messages,
first.message,
Message.tool({ id: call.id, name: call.name, result: result.result }),
],
}),
{ webSocket: channel.executor },
)
expect(second.text).toBe("Paris is sunny.")
expect(channel.opens()).toBe(1)
expect(channel.sent).toHaveLength(2)
expect(channel.sent[1]).toMatchObject({
previous_response_id: expect.any(String),
input: [{ type: "function_call_output", call_id: call.id, output: expect.any(String) }],
})
}),
)
recorded.effect.with("reconstructs full context after reconnect", { tags: ["reconnect", "full-context"] }, () =>
Effect.gen(function* () {
const channel = yield* makeChannel
const request = LLM.request({
id: "recorded_openai_responses_websocket_reconnect",
model,
system: "Follow the user's exact reply instruction.",
prompt: "Reply exactly: Alpha.",
generation: { maxTokens: 30 },
cache: "none",
})
const first = yield* LLMClient.generate(request, { webSocket: channel.executor })
yield* channel.reconnect()
const second = yield* LLMClient.generate(
LLMRequest.update(request, {
messages: [...request.messages, first.message, Message.user("Reply exactly: Beta.")],
}),
{ webSocket: channel.executor },
)
expect(first.text).toBe("Alpha.")
expect(second.text).toBe("Beta.")
expect(channel.opens()).toBe(2)
expect(channel.sent[1]).not.toHaveProperty("previous_response_id")
expect(channel.sent[1]).toMatchObject({
input: [
{ role: "system", content: "Follow the user's exact reply instruction." },
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Alpha." }] },
{ role: "assistant", content: [{ type: "output_text", text: "Alpha." }] },
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Beta." }] },
],
})
}),
)
recorded.effect.with("recovers from explicit continuation rejection", { tags: ["continuation", "recovery"] }, () =>
Effect.gen(function* () {
const channel = yield* makeChannel
const request = LLM.request({
id: "recorded_openai_responses_websocket_rejection",
model,
system: "Follow the user's exact reply instruction.",
prompt: "Reply exactly: Ready.",
generation: { maxTokens: 30 },
cache: "none",
})
const first = yield* LLMClient.generate(request, { webSocket: channel.executor })
const continuation = LLMRequest.update(request, {
messages: [...request.messages, first.message, Message.user("Reply exactly: Recovered.")],
})
yield* channel.reconnect(true)
const rejected = yield* LLMClient.generate(continuation, { webSocket: channel.executor }).pipe(Effect.flip)
const recovered = yield* LLMClient.generate(continuation, { webSocket: channel.executor })
expect(rejected).toMatchObject({
reason: { _tag: "Transport", delivery: "rejected", recovery: "retry-full" },
})
expect(recovered.text).toBe("Recovered.")
expect(channel.opens()).toBe(2)
expect(channel.sent[1]).toHaveProperty("previous_response_id", expect.any(String))
expect(channel.sent[2]).not.toHaveProperty("previous_response_id")
expect(channel.sent[2]).toMatchObject({
input: [
{ role: "system", content: "Follow the user's exact reply instruction." },
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Ready." }] },
{ role: "assistant", content: [{ type: "output_text", text: "Ready." }] },
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Recovered." }] },
],
})
}),
)
})
@@ -263,7 +263,7 @@ describe("OpenAI Responses route", () => {
expect(prepared.body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
{ role: "developer", content: "Operator update." },
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "After." }] },
{ role: "assistant", content: [{ type: "output_text", text: "After." }] },
])
}),
)
@@ -485,7 +485,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("continues store-false reasoning while retaining the output item ID", () =>
it.effect("continues store-false reasoning without replaying the output-only item ID", () =>
Effect.gen(function* () {
const firstInput = [{ role: "user", content: [{ type: "input_text", text: "Think" }] }]
const request = { type: "response.create", model: "gpt-5.2", store: false, input: firstInput }
@@ -515,7 +515,6 @@ describe("OpenAI Responses route", () => {
...firstInput,
{
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Thought" }],
encrypted_content: "encrypted",
},
@@ -691,134 +690,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("builds xAI WebSocket requests without OpenAI handshake headers", () =>
Effect.gen(function* () {
const deps = Layer.succeed(
RequestExecutor.Service,
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
)
const response = yield* LLMClient.generate(LLM.request({ model: xaiModel, prompt: "Say hello." }), {
webSocket: {
execute: (exchange) =>
Effect.gen(function* () {
expect(exchange.connect.url).toBe("wss://api.x.ai/v1/responses")
expect(exchange.connect.rotateAfterMs).toBe(24 * 60 * 1000)
expect(exchange.connect.headers.authorization).toBe("Bearer test")
expect(exchange.connect.headers["openai-beta"]).toBeUndefined()
expect(JSON.parse((yield* exchange.driver.create(undefined)).message)).toMatchObject({
type: "response.create",
model: "grok-4.5",
store: false,
})
return {
frames: Stream.make(
JSON.stringify({ type: "response.created", response: { id: "resp_xai" } }),
JSON.stringify({ type: "response.completed", response: { id: "resp_xai" } }),
),
complete: Effect.void,
}
}),
},
}).pipe(Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))))
expect(response.finishReason.normalized).toBe("stop")
}),
)
it.effect("builds Azure WebSocket requests with v1 URLs and bearer auth", () =>
Effect.gen(function* () {
const deps = Layer.succeed(
RequestExecutor.Service,
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
)
const cases = [
{
model: Azure.configure({ resourceName: "opencode-test", apiKey: "azure-key" }).responses("deployment"),
authorization: "Bearer azure-key",
},
{
model: Azure.configure({ resourceName: "opencode-test", auth: Auth.bearer("entra-token") }).responses(
"deployment",
),
authorization: "Bearer entra-token",
},
]
yield* Effect.forEach(cases, (item) =>
LLMClient.generate(LLM.request({ model: item.model, prompt: "Say hello." }), {
webSocket: {
execute: (exchange) =>
Effect.gen(function* () {
expect(exchange.connect.url).toBe("wss://opencode-test.openai.azure.com/openai/v1/responses")
expect(exchange.connect.rotateAfterMs).toBe(55 * 60 * 1000)
expect(exchange.connect.headers.authorization).toBe(item.authorization)
expect(exchange.connect.headers["api-key"]).toBeUndefined()
expect(exchange.connect.headers["openai-beta"]).toBeUndefined()
expect(JSON.parse((yield* exchange.driver.create(undefined)).message)).toMatchObject({
type: "response.create",
model: "deployment",
store: false,
})
return {
frames: Stream.make(
JSON.stringify({ type: "response.created", response: { id: "resp_azure" } }),
JSON.stringify({ type: "response.completed", response: { id: "resp_azure" } }),
),
complete: Effect.void,
}
}),
},
}).pipe(Effect.provide(LLMClient.layer.pipe(Layer.provide(deps)))),
)
}),
)
it.effect("keeps unsupported Azure endpoints and API versions on HTTP", () =>
Effect.gen(function* () {
const cases = [
{
model: Azure.configure({
resourceName: "opencode-test",
apiKey: "azure-key",
apiVersion: "2025-04-01-preview",
}).responses("deployment"),
url: "https://opencode-test.openai.azure.com/openai/v1/responses?api-version=2025-04-01-preview",
},
{
model: Azure.configure({
resourceName: "opencode-test",
apiKey: "azure-key",
useDeploymentBasedUrls: true,
}).responses("deployment"),
url: "https://opencode-test.openai.azure.com/openai/deployments/deployment/responses?api-version=v1",
},
{
model: Azure.configure({ baseURL: "https://gateway.example/azure", apiKey: "azure-key" }).responses(
"deployment",
),
url: "https://gateway.example/azure/responses",
},
]
yield* Effect.forEach(cases, (item) =>
LLMClient.generate(LLM.request({ model: item.model, prompt: "Say hello." }), {
webSocket: { execute: () => Effect.die("unexpected WebSocket request") },
}).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
expect(input.request.url).toBe(item.url)
return input.respond(sseEvents({ type: "response.completed", response: {} }), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
),
)
}),
)
it.effect("uses exactly one HTTP request when no WebSocket executor is supplied", () =>
Effect.gen(function* () {
const attempts = yield* Ref.make(0)
@@ -1613,7 +1484,7 @@ describe("OpenAI Responses route", () => {
expect(response.text).toBe("Hello!")
expect(response.events).toEqual([
{ type: "step-start", index: 0 },
{ type: "text-start", id: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
{ type: "text-start", id: "msg_1" },
{ type: "text-delta", id: "msg_1", text: "Hello" },
{ type: "text-delta", id: "msg_1", text: "!" },
{ type: "text-end", id: "msg_1" },
@@ -1634,108 +1505,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("preserves standard refusal content as ordinary assistant text", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
output_index: 0,
item: { type: "message", id: "msg_refusal", content: [] },
},
{
type: "response.content_part.added",
item_id: "msg_refusal",
output_index: 0,
content_index: 0,
part: { type: "refusal", refusal: "" },
},
{
type: "response.refusal.delta",
item_id: "msg_refusal",
output_index: 0,
content_index: 0,
delta: "I can't",
},
{
type: "response.refusal.delta",
item_id: "msg_refusal",
output_index: 0,
content_index: 0,
delta: " help with that.",
},
{
type: "response.refusal.done",
item_id: "msg_refusal",
output_index: 0,
content_index: 0,
refusal: "I can't help with that.",
},
{
type: "response.content_part.done",
item_id: "msg_refusal",
output_index: 0,
content_index: 0,
part: { type: "refusal", refusal: "I can't help with that." },
},
{
type: "response.output_item.done",
output_index: 0,
item: {
type: "message",
id: "msg_refusal",
phase: "final_answer",
content: [{ type: "refusal", refusal: "I can't help with that." }],
},
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.text).toBe("I can't help with that.")
expect(response.finishReason).toEqual({ normalized: "stop", raw: undefined })
expect(response.message.content).toEqual([
{
type: "text",
text: "I can't help with that.",
providerMetadata: { openai: { itemId: "msg_refusal", phase: "final_answer" } },
},
])
const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(prepared.body.input).toEqual([
{
type: "message",
id: "msg_refusal",
role: "assistant",
content: [{ type: "output_text", text: "I can't help with that." }],
phase: "final_answer",
},
])
}),
)
it.effect("rejects malformed refusal events", () =>
Effect.gen(function* () {
const events = [
{ type: "response.refusal.delta", output_index: 0, content_index: 0, delta: "missing item" },
{ type: "response.refusal.delta", item_id: "msg_1", output_index: 0, content_index: 0 },
{ type: "response.refusal.done", item_id: "msg_1", output_index: 0, content_index: 0 },
]
for (const event of events) {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents(event))),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
}
}),
)
it.effect("preserves and replays assistant message phases", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
@@ -1774,39 +1543,33 @@ describe("OpenAI Responses route", () => {
{
type: "text",
text: "Checking.",
providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
providerMetadata: { openai: { phase: "commentary" } },
},
{
type: "text",
text: "Finished.",
providerMetadata: { openai: { itemId: "msg_final", phase: "final_answer" } },
providerMetadata: { openai: { phase: "final_answer" } },
},
{
type: "text",
text: "Unclassified.",
providerMetadata: { openai: { itemId: "msg_null", phase: null } },
providerMetadata: { openai: { phase: null } },
},
])
const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(prepared.body.input).toEqual([
{
type: "message",
id: "msg_commentary",
role: "assistant",
content: [{ type: "output_text", text: "Checking." }],
phase: "commentary",
},
{
type: "message",
id: "msg_final",
role: "assistant",
content: [{ type: "output_text", text: "Finished." }],
phase: "final_answer",
},
{
type: "message",
id: "msg_null",
role: "assistant",
content: [{ type: "output_text", text: "Unclassified." }],
phase: null,
@@ -1900,12 +1663,12 @@ describe("OpenAI Responses route", () => {
)
expect(response.events.filter((event) => event.type.startsWith("text-"))).toEqual([
{ type: "text-start", id: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
{ type: "text-start", id: "msg_1" },
{ type: "text-delta", id: "msg_1", text: "First" },
{ type: "text-end", id: "msg_1", providerMetadata: undefined },
{ type: "text-start", id: "msg_2", providerMetadata: { openai: { itemId: "msg_2" } } },
{ type: "text-end", id: "msg_1" },
{ type: "text-start", id: "msg_2" },
{ type: "text-delta", id: "msg_2", text: "Second" },
{ type: "text-end", id: "msg_2", providerMetadata: { openai: { itemId: "msg_2" } } },
{ type: "text-end", id: "msg_2" },
])
}),
)
@@ -1937,7 +1700,7 @@ describe("OpenAI Responses route", () => {
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
expect(response.message.content).toEqual([
{ type: "reasoning", text: "thinking" },
{ type: "text", text: "Hello", providerMetadata: { openai: { itemId: "msg_1" } } },
{ type: "text", text: "Hello" },
])
}),
)
@@ -2098,7 +1861,6 @@ describe("OpenAI Responses route", () => {
{ role: "user", content: [{ type: "input_text", text: "What changed?" }] },
{
type: "reasoning",
id: "rs_1",
encrypted_content: "encrypted-state",
summary: [{ type: "summary_text", text: "Checked the previous diff." }],
},
@@ -2106,6 +1868,7 @@ describe("OpenAI Responses route", () => {
{ role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
],
})
expect(body.input[1]).not.toHaveProperty("id")
return input.respond(
sseEvents(
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Parser now round-trips reasoning." },
@@ -2149,14 +1912,13 @@ describe("OpenAI Responses route", () => {
)
expect(prepared.body.input).toEqual([
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "Before." }] },
{ role: "assistant", content: [{ type: "output_text", text: "Before." }] },
{
type: "reasoning",
id: "rs_1",
encrypted_content: "encrypted-state",
summary: [{ type: "summary_text", text: "Checked order." }],
},
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "After." }] },
{ role: "assistant", content: [{ type: "output_text", text: "After." }] },
])
}),
)
@@ -2288,7 +2050,6 @@ describe("OpenAI Responses route", () => {
expect(prepared.body.input).toEqual([
{
type: "reasoning",
id: "rs_1",
encrypted_content: "encrypted-state",
summary: [
{ type: "summary_text", text: "First" },
@@ -2421,50 +2182,6 @@ describe("OpenAI Responses route", () => {
usage,
},
])
const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(prepared.body.input).toEqual([
{
type: "function_call",
id: "item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
},
])
}),
)
it.effect("finalizes a pending function call at response completion", () =>
Effect.gen(function* () {
const body = sseEvents(
{
type: "response.output_item.added",
item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
)
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
expect(response.events.filter((event) => LLMEvent.is.toolInputEnd(event) || LLMEvent.is.toolCall(event))).toEqual(
[
{
type: "tool-input-end",
id: "call_1",
name: "lookup",
providerMetadata: { openai: { itemId: "item_1" } },
},
{
type: "tool-call",
id: "call_1",
name: "lookup",
input: {},
providerExecuted: undefined,
providerMetadata: { openai: { itemId: "item_1" } },
},
],
)
expect(response.finishReason.normalized).toBe("tool-calls")
}),
)
@@ -2531,35 +2248,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("retains function call item metadata when output_item.added is absent", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.done",
item: {
type: "function_call",
id: "item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
},
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
id: "call_1",
providerMetadata: { openai: { itemId: "item_1" } },
})
}),
)
it.effect("decodes web_search_call as provider-executed tool-call + tool-result", () =>
Effect.gen(function* () {
const item = {
+2 -10
View File
@@ -1,7 +1,5 @@
import { HttpRecorder } from "@opencode-ai/http-recorder"
import { NodeSocket } from "@effect/platform-node"
import { Layer } from "effect"
import { Socket } from "effect/unstable/socket"
import * as path from "node:path"
import { fileURLToPath } from "node:url"
import { LLMClient, RequestExecutor } from "../src/route.js"
@@ -18,7 +16,7 @@ import {
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings")
type RecordedEnv = RequestExecutorService | LLMClientService | ImageClientService | Socket.WebSocketConstructor
type RecordedEnv = RequestExecutorService | LLMClientService | ImageClientService
type RecordedTestsOptions = RecordedGroupOptions & {
readonly options?: HttpRecorder.RecorderOptions
@@ -71,7 +69,7 @@ export const recordedTests = (options: RecordedTestsOptions) =>
...metadata,
}
if (recording) {
if (process.env.CI !== undefined) throw new Error("Unset CI before recording cassettes")
if (process.env.CI !== undefined) throw new Error("Unset CI before recording HTTP cassettes")
HttpRecorder.removeCassetteSync(cassette, { directory: FIXTURES_DIR })
}
const requestExecutor = RequestExecutor.layer.pipe(
@@ -83,16 +81,10 @@ export const recordedTests = (options: RecordedTestsOptions) =>
}),
),
)
const webSocket = HttpRecorder.layerWebSocketConstructor(cassette, {
...recorderOptions,
directory: FIXTURES_DIR,
metadata: recorderMetadata,
}).pipe(Layer.provide(NodeSocket.layerWebSocketConstructorWS))
return Layer.mergeAll(
requestExecutor,
LLMClient.layer.pipe(Layer.provide(requestExecutor)),
ImageClient.layer.pipe(Layer.provide(requestExecutor)),
webSocket,
)
},
})
-22
View File
@@ -2,34 +2,12 @@
The app's high-volume performance diagnostics live under `packages/app/e2e/performance` and are excluded from normal local and CI Playwright discovery. The benchmark config builds the app and serves the production bundle before running scenarios serially.
The `devex` category is the explicit exception to the production-build rule. It measures development commands from submission through a user-visible ready state and has its own Playwright configuration.
Run the suite explicitly from `packages/app`:
```sh
bun run test:bench
```
Run the desktop development startup benchmark from the repository root:
```sh
bun run bench:devex
```
It runs five serial samples of the exact `bun dev:desktop` command. Each sample uses a fresh desktop profile, database, service configuration, service registration, and service process; the desktop selects an isolated ephemeral loopback endpoint. It removes desktop build output and the desktop Vite cache before every run; dependencies, Bun's package cache, and Electron remain installed. The harness stops only that sample's service; it does not stop or change the elected global OpenCode service. The measured endpoint is a visible Home page whose empty-state controls pass Playwright actionability checks. The command's Electron installation check remains inside the measured interval.
Set `DESKTOP_STARTUP_RUNS` only for focused diagnostics:
```sh
DESKTOP_STARTUP_RUNS=1 bun run bench:devex
```
Set `OPENCODE_PERFORMANCE_TRACE_DIR` to capture the renderer's CDP trace from attachment through actionable Home:
```sh
DESKTOP_STARTUP_RUNS=1 OPENCODE_PERFORMANCE_TRACE_DIR=/tmp/opencode-desktop-traces bun run bench:devex
```
PowerShell:
```powershell
+2 -2
View File
@@ -18,9 +18,9 @@ const categories = [
"disabled-by-default-v8.cpu_profiler",
]
export async function startChromeTrace(page: Page, name: string): Promise<undefined | (() => Promise<string>)> {
export async function startChromeTrace(page: Page, name: string) {
const directory = process.env.OPENCODE_PERFORMANCE_TRACE_DIR
if (!directory) return undefined
if (!directory) return
const selectors = process.env.OPENCODE_PERFORMANCE_SELECTOR_TRACE === "1"
const file = await prepareChromeTrace(directory, name, selectors)
@@ -1,26 +0,0 @@
import { benchmark } from "../benchmark"
import {
desktopBenchmarkContext,
runDesktopStartup,
summarizeDesktopStartup,
type DesktopStartupSample,
} from "./desktop-startup"
benchmark.describe("devex: desktop startup", () => {
benchmark("opens a cold desktop on Home", async ({ report }, testInfo) => {
benchmark.setTimeout(15 * 60_000)
const runs = Number(process.env.DESKTOP_STARTUP_RUNS ?? 5)
if (!Number.isSafeInteger(runs) || runs < 1) throw new Error("DESKTOP_STARTUP_RUNS must be a positive integer")
const samples: DesktopStartupSample[] = []
const context = await desktopBenchmarkContext(runs)
for (let run = 1; run <= runs; run++) {
const sample = await runDesktopStartup(run, testInfo).catch((error) => {
report(samples.length ? { samples, summary: summarizeDesktopStartup(samples) } : { samples }, context)
throw error
})
samples.push(sample)
}
report({ samples, summary: summarizeDesktopStartup(samples) }, context)
})
})
@@ -1,526 +0,0 @@
import { Service } from "@opencode-ai/client/service"
import { chromium, expect, type Browser, type Page, type TestInfo } from "@playwright/test"
import { spawn, spawnSync, type ChildProcess } from "node:child_process"
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join, resolve } from "node:path"
import { startChromeTrace } from "../chrome-trace"
const repository = resolve(import.meta.dirname, "../../../../..")
const milestones = [
"bunRootScript",
"bunDesktopScript",
"desktopPrepared",
"mainBundleReady",
"preloadBundleReady",
"rendererDevServerReady",
"electronSpawnStarted",
"debugEndpointReady",
"electronStarted",
"serviceEnsureStarted",
"serviceSpawnRequested",
"serviceReady",
"backgroundLoadingReady",
"rendererViteConnected",
"rendererInitializationStarted",
"rendererInitializationReady",
"windowVisible",
"homeReady",
] as const
const phases = [
"desktopPreparation",
"viteMainBundle",
"vitePreloadBundle",
"rendererServerStartup",
"electronStartup",
"serviceSpawnWait",
"serviceProcessStartup",
"rendererStartup",
"visibleWindowToHome",
] as const
type Milestone = (typeof milestones)[number]
type Phase = (typeof phases)[number]
type ServiceInfo = { id: string; version: string; url: string; pid: number }
export type DesktopStartupSample = {
run: number
commandToHomeReadyMs: number
milestonesMs: Record<Milestone, number>
phasesMs: Record<Phase, number>
service: Omit<ServiceInfo, "id">
}
export async function runDesktopStartup(run: number, testInfo: TestInfo) {
const profile = await createColdProfile()
const desktop = await Promise.resolve()
.then(() => startDesktop(profile))
.catch(async (error) => {
await rm(profile.root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
throw error
})
try {
const page = await desktop.open()
const stopTrace = await startChromeTrace(page, `desktop-startup-${run}`)
try {
await startThemeObservation(page)
await waitForHome(page, desktop.mark)
await requireStableTheme(page)
return await desktop.result(run)
} finally {
await stopTrace?.()
}
} finally {
await desktop.close(testInfo, run)
}
}
export async function desktopBenchmarkContext(runs: number) {
const pkg = JSON.parse(await readFile(join(repository, "packages/desktop/package.json"), "utf8"))
const revision = spawnSync("git", ["rev-parse", "HEAD"], { cwd: repository })
if (revision.status !== 0) throw new Error("Failed to read the benchmark Git revision")
const status = spawnSync("git", ["status", "--porcelain"], { cwd: repository })
if (status.status !== 0) throw new Error("Failed to read the benchmark Git status")
const bun = spawnSync("bun", ["--version"], { cwd: repository })
if (bun.status !== 0) throw new Error("Failed to read the benchmark Bun version")
return {
arch: process.arch,
command: "bun dev:desktop",
runs,
profile: "fresh",
service: "isolated-cold",
install: "complete",
viteCache: "cold",
electronInstall: "present",
bunVersion: bun.stdout.toString().trim(),
electronVersion: pkg.devDependencies.electron,
electronViteVersionRange: pkg.devDependencies["electron-vite"],
gitCommit: revision.stdout.toString().trim(),
gitDirty: status.stdout.length > 0,
trace: Boolean(process.env.OPENCODE_PERFORMANCE_TRACE_DIR),
}
}
export function summarizeDesktopStartup(samples: DesktopStartupSample[]) {
return {
commandToHomeReadyMs: statistics(samples.map((sample) => sample.commandToHomeReadyMs)),
milestonesMs: Object.fromEntries(
milestones.map((name) => [name, statistics(samples.map((sample) => sample.milestonesMs[name]))]),
),
phasesMs: Object.fromEntries(
phases.map((name) => [name, statistics(samples.map((sample) => sample.phasesMs[name]))]),
),
}
}
export function milestoneForLine(line: string): Milestone | undefined {
const text = stripAnsi(line)
return milestonePatterns.find((item) => text.includes(item.text))?.name
}
const milestonePatterns: ReadonlyArray<{ name: Milestone; text: string }> = [
{ name: "bunRootScript", text: "$ bun --cwd packages/desktop dev" },
{ name: "bunDesktopScript", text: "$ bun ./scripts/dev.ts" },
{ name: "desktopPrepared", text: "Copied dev icons from" },
{ name: "mainBundleReady", text: "electron main process built successfully" },
{ name: "preloadBundleReady", text: "electron preload scripts built successfully" },
{ name: "rendererDevServerReady", text: "dev server running for the electron renderer process at:" },
{ name: "electronSpawnStarted", text: "starting electron app..." },
{ name: "debugEndpointReady", text: "DevTools listening on ws://" },
{ name: "electronStarted", text: "app starting" },
{ name: "serviceEnsureStarted", text: "starting v2 background service" },
{ name: "serviceSpawnRequested", text: "v2 CLI background service starting" },
{ name: "serviceReady", text: "v2 CLI background service ready" },
{ name: "backgroundLoadingReady", text: "loading task finished" },
{ name: "rendererViteConnected", text: "[vite] connected." },
{ name: "rendererInitializationStarted", text: "awaiting server ready" },
{ name: "rendererInitializationReady", text: "server ready" },
{ name: "windowVisible", text: "main window visible" },
]
async function createColdProfile() {
await Promise.all(
["packages/desktop/node_modules/.vite", "packages/desktop/out"].map((path) =>
rm(join(repository, path), { recursive: true, force: true }),
),
)
const root = await mkdtemp(join(tmpdir(), "opencode-desktop-startup-"))
return initializeColdProfile(root).catch(async (error) => {
await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
throw error
})
}
async function initializeColdProfile(root: string) {
await Promise.all(
["data", "config", "cache", "state", "desktop", "session", "home"].map((dir) =>
mkdir(join(root, dir), { recursive: true }),
),
)
await Promise.all([
writeFile(
join(root, "desktop", "opencode.settings"),
JSON.stringify({ firstLaunchOnboardingComplete: true }),
),
writeFile(join(root, "desktop", "opencode.global.dat"), JSON.stringify({ language: '{"locale":"en"}' })),
])
const registration = join(root, "desktop", "opencode", "service-local.json")
await Service.stop({ file: registration })
return { root, registration }
}
function startDesktop(profile: Awaited<ReturnType<typeof createColdProfile>>) {
const started = performance.now()
const child = spawn("bun", ["dev:desktop"], {
cwd: repository,
detached: process.platform !== "win32",
env: {
...process.env,
OPENCODE_CONFIG_DIR: join(profile.root, "config"),
OPENCODE_DB: join(profile.root, "data", "opencode.db"),
OPENCODE_TEST_HOME: join(profile.root, "home"),
OPENCODE_TEST_ONBOARDING: "0",
OPENCODE_DESKTOP_TEST_ROOT: profile.root,
OPENCODE_DESKTOP_REMOTE_DEBUGGING_PORT: "0",
OPENCODE_DESKTOP_DISABLE_PROTOCOL_REGISTRATION: "1",
},
stdio: ["ignore", "pipe", "pipe"],
})
if (!child.pid || !child.stdout || !child.stderr) throw new Error("Failed to start the desktop command")
const exited = childExit(child)
const observed: Partial<Record<Milestone, number>> = {}
const endpoint = Promise.withResolvers<string>()
const pageErrors: string[] = []
let browser: Browser | undefined
let service: ServiceInfo | undefined
const mark = (name: Milestone) => {
observed[name] ??= elapsed(started)
}
const record = (line: string) => {
const milestone = milestoneForLine(line)
if (milestone) mark(milestone)
const match = stripAnsi(line).match(/DevTools listening on (ws:\/\/\S+)/)
if (match?.[1]) endpoint.resolve(match[1])
}
const stdout = observeOutput(child.stdout, record)
const stderr = observeOutput(child.stderr, record)
return {
mark,
async open() {
const url = await Promise.race([
endpoint.promise,
exited.then((code) => {
throw new Error(`Desktop command exited with code ${code} before opening its debug endpoint`)
}),
sleep(120_000).then(() => {
throw new Error("Timed out waiting for the desktop debug endpoint")
}),
])
browser = await chromium.connectOverCDP(url, { timeout: 120_000 })
const context = browser.contexts()[0]
if (!context) throw new Error("Electron did not expose a browser context")
await expect.poll(() => context.pages().length, { timeout: 120_000 }).toBeGreaterThan(0)
const page = context.pages()[0]
if (!page) throw new Error("Electron did not expose a renderer page")
page.on("pageerror", (error) => pageErrors.push(error.stack ?? error.message))
return page
},
async result(run: number): Promise<DesktopStartupSample> {
if (pageErrors.length) throw new Error(`Desktop renderer reported errors:\n\n${pageErrors.join("\n\n")}`)
service = await readService(profile)
const milestonesMs = requireMilestones(observed)
return {
run,
commandToHomeReadyMs: milestonesMs.homeReady,
milestonesMs,
phasesMs: calculatePhases(milestonesMs),
service: {
version: service.version,
url: service.url,
pid: service.pid,
},
}
},
async close(testInfo: TestInfo, run: number) {
const errors: unknown[] = []
await browser?.close().catch(() => undefined)
await stopProcessTree(child, exited).catch((error) => {
errors.push(error)
child.stdout?.destroy()
child.stderr?.destroy()
})
const [stdoutText, stderrText] = await Promise.all([stdout, stderr]).catch((error) => {
errors.push(error)
return ["", ""]
})
await Promise.all([
testInfo.attach(`desktop-startup-${run}-stdout`, { body: stdoutText, contentType: "text/plain" }),
testInfo.attach(`desktop-startup-${run}-stderr`, { body: stderrText, contentType: "text/plain" }),
pageErrors.length
? testInfo.attach(`desktop-startup-${run}-page-errors`, {
body: pageErrors.join("\n\n"),
contentType: "text/plain",
})
: Promise.resolve(),
]).catch((error) => errors.push(error))
await Service.stop({ file: profile.registration }).catch((error) => errors.push(error))
if (service && processAlive(service.pid))
errors.push(new Error(`Desktop service process ${service.pid} did not stop`))
await rm(profile.root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch((error) =>
errors.push(error),
)
if (errors.length) throw new AggregateError(errors, "Desktop benchmark cleanup failed")
},
}
}
async function waitForHome(page: Page, mark: (name: Milestone) => void) {
await expect.poll(() => page.evaluate(() => document.visibilityState), { timeout: 120_000 }).toBe("visible")
const projects = page.getByRole("complementary", { name: "Projects", exact: true })
const sessions = page.getByRole("region", { name: "Recent sessions", exact: true })
const search = page.getByRole("textbox", { name: "Search sessions", exact: true })
const addProject = projects.locator('button[data-action="home-add-project-row"]')
await expect(projects).toBeVisible({ timeout: 120_000 })
await expect(sessions).toBeVisible()
await expect(search).toBeEditable()
await expect(sessions.getByText("Nothing here yet", { exact: true })).toBeVisible()
await expect(addProject).toHaveCount(1)
await addProject.click({ trial: true })
mark("homeReady")
}
type ThemeWindow = Window & {
__OPENCODE_THEME_STATES__?: string[]
__OPENCODE_THEME_OBSERVER__?: MutationObserver
}
async function startThemeObservation(page: Page) {
await page.addInitScript(installThemeObservation)
await page.evaluate(installThemeObservation)
}
async function requireStableTheme(page: Page) {
const states = await page.evaluate(() => {
const target = window as ThemeWindow
target.__OPENCODE_THEME_OBSERVER__?.disconnect()
return target.__OPENCODE_THEME_STATES__ ?? []
})
if (states.length !== 1) throw new Error(`Desktop theme changed during startup: ${states.join(" -> ")}`)
}
function installThemeObservation() {
const target = window as ThemeWindow
const observeRoot = () => {
const root = document.documentElement
if (!root) return false
const state = () => {
const theme = root.dataset.theme
const scheme = root.dataset.colorScheme
return theme && scheme ? `${theme}:${scheme}` : undefined
}
const initial = state()
target.__OPENCODE_THEME_STATES__ = initial ? [initial] : []
target.__OPENCODE_THEME_OBSERVER__ = new MutationObserver(() => {
const next = state()
if (!next) return
if (target.__OPENCODE_THEME_STATES__?.at(-1) !== next) target.__OPENCODE_THEME_STATES__?.push(next)
})
target.__OPENCODE_THEME_OBSERVER__.observe(root, {
attributes: true,
attributeFilter: ["data-theme", "data-color-scheme"],
})
return true
}
if (observeRoot()) return
const documentObserver = new MutationObserver(() => {
if (!observeRoot()) return
documentObserver.disconnect()
})
target.__OPENCODE_THEME_OBSERVER__ = documentObserver
documentObserver.observe(document, { childList: true })
}
async function observeOutput(stream: NodeJS.ReadableStream, record: (line: string) => void) {
const decoder = new TextDecoder()
const output: string[] = []
let pending = ""
for await (const chunk of stream) {
const text = typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true })
output.push(text)
pending += text
const lines = pending.split(/\r?\n/)
pending = lines.pop() ?? ""
lines.forEach(record)
}
const final = decoder.decode()
output.push(final)
pending += final
if (pending) record(pending)
return output.join("")
}
async function readService(profile: Awaited<ReturnType<typeof createColdProfile>>) {
const value: unknown = JSON.parse(await readFile(profile.registration, "utf8"))
if (!isServiceInfo(value)) throw new Error("Desktop service registration is invalid")
const url = new URL(value.url)
const port = Number(url.port)
if (url.hostname !== "127.0.0.1" || !Number.isInteger(port) || port <= 0)
throw new Error(`Desktop service used unexpected endpoint ${value.url}`)
if (!value.version.startsWith("2.0.0-local-"))
throw new Error(`Desktop service used unexpected version ${value.version}`)
return value
}
function isServiceInfo(value: unknown): value is ServiceInfo {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
typeof value.id === "string" &&
"version" in value &&
typeof value.version === "string" &&
"url" in value &&
typeof value.url === "string" &&
"pid" in value &&
typeof value.pid === "number"
)
}
function requireMilestones(observed: Partial<Record<Milestone, number>>) {
const get = (name: Milestone) => {
const value = observed[name]
if (value === undefined) throw new Error(`Desktop startup did not report milestone: ${name}`)
return round(value)
}
return {
bunRootScript: get("bunRootScript"),
bunDesktopScript: get("bunDesktopScript"),
desktopPrepared: get("desktopPrepared"),
mainBundleReady: get("mainBundleReady"),
preloadBundleReady: get("preloadBundleReady"),
rendererDevServerReady: get("rendererDevServerReady"),
electronSpawnStarted: get("electronSpawnStarted"),
debugEndpointReady: get("debugEndpointReady"),
electronStarted: get("electronStarted"),
serviceEnsureStarted: get("serviceEnsureStarted"),
serviceSpawnRequested: get("serviceSpawnRequested"),
serviceReady: get("serviceReady"),
backgroundLoadingReady: get("backgroundLoadingReady"),
rendererViteConnected: get("rendererViteConnected"),
rendererInitializationStarted: get("rendererInitializationStarted"),
rendererInitializationReady: get("rendererInitializationReady"),
windowVisible: get("windowVisible"),
homeReady: get("homeReady"),
}
}
function calculatePhases(value: Record<Milestone, number>): Record<Phase, number> {
return {
desktopPreparation: value.desktopPrepared,
viteMainBundle: round(value.mainBundleReady - value.desktopPrepared),
vitePreloadBundle: round(value.preloadBundleReady - value.mainBundleReady),
rendererServerStartup: round(value.rendererDevServerReady - value.preloadBundleReady),
electronStartup: round(value.electronStarted - value.electronSpawnStarted),
serviceSpawnWait: round(value.serviceSpawnRequested - value.serviceEnsureStarted),
serviceProcessStartup: round(value.serviceReady - value.serviceSpawnRequested),
rendererStartup: round(value.homeReady - value.rendererViteConnected),
visibleWindowToHome: round(value.homeReady - value.windowVisible),
}
}
function statistics(values: number[]) {
if (!values.length) throw new Error("Cannot summarize an empty benchmark")
const sorted = values.toSorted((left, right) => left - right)
const median = medianOf(sorted)
return {
min: round(sorted[0]),
median: round(median),
max: round(sorted.at(-1)!),
medianAbsoluteDeviation: round(medianOf(sorted.map((value) => Math.abs(value - median)).toSorted((a, b) => a - b))),
}
}
function medianOf(sorted: number[]) {
const middle = Math.floor(sorted.length / 2)
if (sorted.length % 2) return sorted[middle]
return (sorted[middle - 1] + sorted[middle]) / 2
}
async function stopProcessTree(child: ChildProcess, exited: Promise<number | null>) {
if (!child.pid) throw new Error("Desktop command has no process ID")
if (process.platform !== "win32") return stopProcessGroup(child.pid, exited)
if (child.exitCode !== null || (await exitsWithin(child, exited, 2_000))) return
const kill = spawn("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], {
stdio: "ignore",
})
await childExit(kill)
if (await exitsWithin(child, exited, 10_000)) return
if (!(await exitsWithin(child, exited, 5_000))) throw new Error(`Desktop command process ${child.pid} did not stop`)
}
async function stopProcessGroup(pid: number, exited: Promise<number | null>) {
await Promise.race([exited, sleep(2_000)])
if (!processGroupAlive(pid)) return
process.kill(-pid, "SIGTERM")
if (await processGroupStopsWithin(pid, 10_000)) return
process.kill(-pid, "SIGKILL")
if (!(await processGroupStopsWithin(pid, 5_000))) throw new Error(`Desktop command process group ${pid} did not stop`)
}
async function processGroupStopsWithin(pid: number, timeout: number) {
const deadline = Date.now() + timeout
while (Date.now() < deadline) {
if (!processGroupAlive(pid)) return true
await sleep(50)
}
return !processGroupAlive(pid)
}
function processGroupAlive(pid: number) {
try {
process.kill(-pid, 0)
return true
} catch {
return false
}
}
async function exitsWithin(child: ChildProcess, exited: Promise<number | null>, timeout: number) {
if (child.exitCode !== null) return true
const result = await Promise.race([exited.then(() => true), sleep(timeout).then(() => false)])
return result
}
function childExit(child: ChildProcess) {
return new Promise<number | null>((resolve, reject) => {
child.once("error", reject)
child.once("exit", (code) => resolve(code))
})
}
function sleep(milliseconds: number) {
return new Promise<void>((resolve) => setTimeout(resolve, milliseconds))
}
function processAlive(pid: number) {
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
function stripAnsi(value: string) {
return value.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
}
function elapsed(started: number) {
return round(performance.now() - started)
}
function round(value: number) {
return Math.round(value * 100) / 100
}
@@ -1,17 +0,0 @@
import { defineConfig } from "@playwright/test"
process.env.OPENCODE_PERFORMANCE_RUN_ID ??= `${new Date().toISOString().replace(/[:.]/g, "-")}-${process.pid}`
export default defineConfig({
testDir: ".",
testMatch: "desktop-startup-benchmark.spec.ts",
outputDir: "../../test-results/performance-devex",
timeout: 15 * 60_000,
expect: {
timeout: 120_000,
},
fullyParallel: false,
workers: 1,
reporter: [["html", { outputFolder: "../../playwright-report/performance-devex", open: "never" }], ["line"]],
projects: [{ name: "desktop" }],
})
@@ -7,7 +7,7 @@ process.env.OPENCODE_PERFORMANCE_RUN_ID ??= `${new Date().toISOString().replace(
export default {
...config,
testDir: ".",
testIgnore: ["unit/**", "devex/**"],
testIgnore: "unit/**",
outputDir: "../test-results/performance",
fullyParallel: false,
workers: 1,
@@ -11,7 +11,7 @@ import {
} from "./timeline-test-helpers"
import { waitForStableTimeline } from "./session-tab-switch-probe"
const contentSelector = '[data-message-id], [data-component="composer-editor"]'
const contentSelector = '[data-message-id], [data-component="prompt-input"]'
const draftID = "draft_first_navigation"
benchmark.describe("performance: first navigation paint", () => {
@@ -41,11 +41,11 @@ benchmark.describe("performance: first navigation paint", () => {
href,
destinationPath: href,
sourceSelector: messageSelector(fixture.expected.sourceMessageIDs.at(-1)!),
destinationSelector: '[data-component="composer-editor"]',
destinationSelector: '[data-component="prompt-input"]',
contentSelector,
navigate: async () => {
await page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first().click()
await expect(page.locator('[data-component="composer-editor"]')).toBeVisible()
await expect(page.locator('[data-component="prompt-input"]')).toBeVisible()
},
})
report(result)
@@ -1,88 +0,0 @@
import { describe, expect, test } from "bun:test"
import { inlineThemePreload } from "../../../vite.js"
import { milestoneForLine, summarizeDesktopStartup, type DesktopStartupSample } from "../devex/desktop-startup"
describe("desktop startup benchmark", () => {
test.each(["/oc-theme-preload.js", "./oc-theme-preload.js"])("inlines %s before the renderer runs", (path) => {
const html = inlineThemePreload(`<script id="oc-theme-preload-script" src="${path}"></script>`)
expect(html).not.toContain(" src=")
expect(html).toContain("opencode-color-scheme")
})
test("recognizes startup milestones in colored output", () => {
const cases = [
["bunRootScript", "$ bun --cwd packages/desktop dev"],
["bunDesktopScript", "$ bun ./scripts/dev.ts"],
["desktopPrepared", "Copied dev icons from"],
["mainBundleReady", "electron main process built successfully"],
["preloadBundleReady", "electron preload scripts built successfully"],
["rendererDevServerReady", "dev server running for the electron renderer process at:"],
["electronSpawnStarted", "starting electron app..."],
["debugEndpointReady", "DevTools listening on ws://"],
["electronStarted", "app starting"],
["serviceEnsureStarted", "starting v2 background service"],
["serviceSpawnRequested", "v2 CLI background service starting"],
["serviceReady", "v2 CLI background service ready"],
["backgroundLoadingReady", "loading task finished"],
["rendererViteConnected", "[vite] connected."],
["rendererInitializationStarted", "awaiting server ready"],
["rendererInitializationReady", "server ready"],
["windowVisible", "main window visible"],
] as const
cases.forEach(([milestone, line]) => {
expect(milestoneForLine(`\u001b[32m${line}\u001b[39m`)).toBe(milestone)
})
expect(milestoneForLine("12:30:00.000 v2 CLI background service ready {")).toBe("serviceReady")
expect(milestoneForLine("unrelated output")).toBeUndefined()
})
test("keeps raw samples and reports median absolute deviation", () => {
const samples = [24, 20, 22, 28, 26].map((commandToHomeReadyMs, index) => sample(index + 1, commandToHomeReadyMs))
expect(summarizeDesktopStartup(samples).commandToHomeReadyMs).toEqual({
min: 20,
median: 24,
max: 28,
medianAbsoluteDeviation: 2,
})
})
})
function sample(run: number, commandToHomeReadyMs: number): DesktopStartupSample {
const milestonesMs = {
bunRootScript: 1,
bunDesktopScript: 2,
desktopPrepared: 3,
mainBundleReady: 4,
preloadBundleReady: 5,
rendererDevServerReady: 6,
electronSpawnStarted: 7,
debugEndpointReady: 8,
electronStarted: 9,
serviceEnsureStarted: 10,
serviceSpawnRequested: 11,
serviceReady: 12,
backgroundLoadingReady: 13,
rendererViteConnected: 14,
rendererInitializationStarted: 15,
rendererInitializationReady: 16,
windowVisible: 17,
homeReady: commandToHomeReadyMs,
}
return {
run,
commandToHomeReadyMs,
milestonesMs,
phasesMs: {
desktopPreparation: 3,
viteMainBundle: 1,
vitePreloadBundle: 1,
rendererServerStartup: 1,
electronStartup: 2,
serviceSpawnWait: 1,
serviceProcessStartup: 1,
rendererStartup: commandToHomeReadyMs - 14,
visibleWindowToHome: commandToHomeReadyMs - 17,
},
service: { version: "2.0.0-local-test", url: "http://127.0.0.1:3000", pid: run },
}
}
@@ -5,11 +5,9 @@ import { mockOpenCodeServer } from "../../utils/mock-server"
test("applies message latency after a list response gate is released", async () => {
const events: string[] = []
const gate = Promise.withResolvers<void>()
const started = Promise.withResolvers<void>()
let handler: ((route: Route) => Promise<void>) | undefined
const page = {
addInitScript: () => Promise.resolve(),
on: () => page,
route: (_url: string, callback: (route: Route) => Promise<void>) => {
handler = callback
return Promise.resolve()
@@ -23,7 +21,6 @@ test("applies message latency after a list response gate is released", async ()
messageDelay: 25,
beforeMessagesResponse: () => {
events.push("before")
started.resolve()
return gate.promise
},
onMessages: (request) => events.push(request.phase),
@@ -34,18 +31,12 @@ test("applies message latency after a list response gate is released", async ()
})
const response = handler!({
request: () => ({
url: () => "http://127.0.0.1:4096/api/session/session/message",
method: () => "GET",
headers: () => ({}),
postDataBuffer: () => null,
}),
request: () => ({ url: () => "http://127.0.0.1:4096/api/session/session/message" }),
fulfill: () => {
events.push("fulfill")
return Promise.resolve()
},
} as unknown as Route)
await started.promise
expect(events).toEqual(["start", "before"])
const released = performance.now()
@@ -54,42 +45,3 @@ test("applies message latency after a list response gate is released", async ()
expect(performance.now() - released).toBeGreaterThanOrEqual(20)
expect(events).toEqual(["start", "before", "page", "end", "fulfill"])
})
test("routes requests through the HttpApi contract", async () => {
const connected = Promise.withResolvers<{ integrationID: string; body: unknown }>()
let handler: ((route: Route) => Promise<void>) | undefined
const page = {
addInitScript: () => Promise.resolve(),
on: () => page,
route: (_url: string, callback: (route: Route) => Promise<void>) => {
handler = callback
return Promise.resolve()
},
} as unknown as Page
await mockOpenCodeServer(page, {
provider: {},
directory: "C:/OpenCode",
project: {},
sessions: [],
pageMessages: () => ({ items: [] }),
onConnectKey: connected.resolve,
})
const body = Buffer.from(JSON.stringify({ key: "secret" }))
let status: number | undefined
await handler!({
request: () => ({
url: () => "http://127.0.0.1:4096/api/integration/anthropic/connect/key",
method: () => "POST",
headers: () => ({ "content-type": "application/json" }),
postDataBuffer: () => body,
}),
fulfill: (response: Parameters<Route["fulfill"]>[0]) => {
status = response?.status
return Promise.resolve()
},
} as unknown as Route)
expect(status).toBe(204)
expect(await connected.promise).toEqual({ integrationID: "anthropic", body: { key: "secret" } })
})
@@ -46,7 +46,7 @@ test("matches the rounded panel corners to the dark new-session background", asy
)
await page.goto(`/new-session?draftId=${draftID}`)
await expectAppVisible(page.locator('[data-component="composer-editor"]'))
await expectAppVisible(page.locator('[data-component="prompt-input"]'))
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
const panel = page.locator('main div[class*="rounded-[10px]"][class*="overflow-hidden"]')
await expect(panel).toHaveCount(1)
@@ -60,7 +60,7 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
if (path) return []
return [
{
name: "",
name: "frontend",
path: "frontend\\",
absolute: `${directory}/frontend`,
type: "directory" as const,
@@ -116,7 +116,6 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
const frontendRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend"]')
await expect(frontendRow).toBeVisible()
await expect(frontendRow.getByText("frontend", { exact: true })).toBeVisible()
await expect(frontendRow).toHaveAttribute("aria-expanded", "false")
await frontendRow.click()
await expect(frontendRow).toHaveAttribute("aria-expanded", "true")
@@ -3,9 +3,9 @@ import { base64Encode } from "@opencode-ai/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
const directory = "C:/OpenCode/ComposerEditing"
const projectID = "proj_composer_editing"
const sessionID = "ses_composer_editing"
const directory = "C:/OpenCode/PromptInputV2Editing"
const projectID = "proj_prompt_input_v2_editing"
const sessionID = "ses_prompt_input_v2_editing"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test("preserves the draft when a populated command menu triggers a built-in", async ({ page }) => {
@@ -15,7 +15,7 @@ test("preserves the draft when a populated command menu triggers a built-in", as
id: projectID,
worktree: directory,
vcs: "git",
name: "composer-editing",
name: "prompt-input-v2-editing",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
@@ -23,10 +23,10 @@ test("preserves the draft when a populated command menu triggers a built-in", as
sessions: [
{
id: sessionID,
slug: "composer-editing",
slug: "prompt-input-v2-editing",
projectID,
directory,
title: "Composer editing",
title: "Prompt input V2 editing",
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
@@ -34,11 +34,8 @@ test("preserves the draft when a populated command menu triggers a built-in", as
pageMessages: () => ({ items: [] }),
})
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
const composer = page.locator('[data-component="composer"]')
const input = composer.locator('[data-component="composer-editor"]')
await expect
.poll(() => input.evaluate((element) => getComputedStyle(element, "::before").content))
.toBe(`"${String.fromCodePoint(0x200b)}"`)
const composer = page.locator('[data-component="prompt-input-v2"]')
const input = composer.locator('[data-component="prompt-input"]')
await expectAppVisible(composer)
await input.fill("keep me")
@@ -8,7 +8,7 @@ const projectID = "proj_prompt_thinking_level_regression"
const sessionID = "ses_prompt_thinking_level_regression"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test("shows the thinking level control while relevant", async ({ page }) => {
test("shows the V2 thinking level control while relevant", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
@@ -51,8 +51,8 @@ test("shows the thinking level control while relevant", async ({ page }) => {
pageMessages: () => ({ items: [] }),
})
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
const composer = page.locator('[data-component="composer"]')
const input = composer.locator('[data-component="composer-editor"]')
const composer = page.locator('[data-component="prompt-input-v2"]')
const input = composer.locator('[data-component="prompt-input"]')
const control = composer.getByRole("button", { name: "Choose model variant" })
await expectAppVisible(composer)
@@ -19,10 +19,10 @@ test("session settings use the remote server context", async ({ page }) => {
await configureServers(page)
await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
await expect(page.getByRole("heading", { name: sessionB.title, exact: true })).toBeVisible()
await expect(page.getByText(sessionB.title).first()).toBeVisible()
await page.keyboard.press("Control+,")
const dialog = page.locator(".settings-dialog")
const dialog = page.locator(".settings-v2-dialog")
const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]')
const input = autoAccept.getByRole("switch")
await expect(autoAccept).toBeVisible()
@@ -61,9 +61,9 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
await expect(page.getByRole("heading", { name: sessionA.title, exact: true })).toBeVisible()
await expect(page.getByText(sessionA.title).first()).toBeVisible()
await page.keyboard.press("Control+,")
const autoAccept = page.locator(".settings-dialog").locator('[data-action="settings-auto-accept-permissions"]')
const autoAccept = page.locator(".settings-v2-dialog").locator('[data-action="settings-auto-accept-permissions"]')
await autoAccept.locator('[data-slot="switch-control"]').click()
await expect(autoAccept.getByRole("switch")).toBeChecked()
await expect
@@ -78,7 +78,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
await page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`).click()
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
await expect(page.getByRole("heading", { name: sessionB.title, exact: true })).toBeVisible()
await expect(page.getByText(sessionB.title).first()).toBeVisible()
await transport.waitForConnection()
await transport.send({
@@ -208,7 +208,7 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
return json(route, [
{
id: remote ? sessionB.projectID : "project-server-a",
canonical: directory,
worktree: directory,
vcs: "git",
time: { created: 1, updated: 1 },
sandboxes: [],
@@ -216,7 +216,7 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
])
}
if (url.pathname === "/api/project/current")
return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory, canonical: directory })
return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory })
if (url.pathname === "/api/session")
return json(route, { data: sessions.map((session) => currentSession(session)), cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
@@ -224,8 +224,6 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/message`))
return json(route, { data: [], cursor: {} })
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/inbox`))
return json(route, { data: [] })
if (url.pathname === "/api/location") return json(route, { directory })
if (url.pathname === "/api/vcs")
return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } })
@@ -239,7 +237,7 @@ function session(id: string, directory: string, title: string) {
id,
slug: id,
projectID: `project-${id}`,
location: { directory },
directory,
title,
version: "dev",
time: { created: 1, updated: 1 },
@@ -76,7 +76,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
}),
})
})
await page.route(/\/api\/pty(?:\?.*)?$/, (route) =>
await page.route("**/pty*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
@@ -1,79 +0,0 @@
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { base64Encode } from "@opencode-ai/util/encode"
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/SessionMessageRevert"
const projectID = "proj_session_message_revert"
const sessionID = "ses_session_message_revert"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const messages = [
{ id: "msg_first", type: "user", text: "First prompt", time: { created: 1 } },
{
id: "msg_first_reply",
type: "assistant",
agent: "build",
model: { id: "test", providerID: "opencode" },
content: [{ type: "text", text: "First reply" }],
time: { created: 2, completed: 3 },
},
{ id: "msg_second", type: "user", text: "Second prompt", time: { created: 4 } },
] satisfies SessionMessageInfo[]
test("reverts directly to the selected user message", async ({ page }) => {
const staged: { sessionID: string; messageID: string }[] = []
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
canonical: directory,
vcs: "git",
name: "session-message-revert",
time: { created: 1, updated: 1 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { test: { id: "test", name: "Test", variants: {}, limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "test" },
},
sessions: [
{
id: sessionID,
slug: "session-message-revert",
projectID,
directory,
title: "Session message revert",
agent: "build",
model: { id: "test", providerID: "opencode" },
version: "dev",
time: { created: 1, updated: 4 },
},
],
pageMessages: () => ({ items: messages }),
onRevertStage: (input) => staged.push(input),
})
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, "Session message revert")
const message = page.locator('[data-message-id="msg_second"]')
await message.hover()
const response = page.waitForResponse(
(response) =>
response.request().method() === "POST" &&
new URL(response.url()).pathname === `/api/session/${sessionID}/revert/stage`,
)
await message.getByRole("button", { name: "Revert message" }).click()
expect((await response).ok()).toBe(true)
await expect(page.getByRole("textbox", { name: "Prompt" })).toHaveText("Second prompt")
expect(staged).toEqual([{ sessionID, messageID: "msg_second" }])
})
@@ -120,7 +120,7 @@ test("restores the draft caret before typing after a request dock closes", async
await transport.waitForConnection()
await expectSessionTitle(page, title)
const editor = page.locator('[data-component="composer-editor"][contenteditable="true"]')
const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]')
const draft = "keep the caret at the end"
await editor.fill(draft)
await page.evaluate(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())))
@@ -103,7 +103,7 @@ test("labels completed searches with result counts", async ({ page }) => {
await expect(rows.nth(1)).toContainText("(12 matches)")
})
test("labels read tools from their path input", async ({ page }) => {
test("labels V2 read tools from their path input", async ({ page }) => {
const id = "prt_read_path"
await setupTimeline(page, {
messages: [userMessage(), assistantMessage([toolPart(id, "read", "completed", { path: "src/a.ts" })])],
@@ -114,7 +114,7 @@ test("labels read tools from their path input", async ({ page }) => {
await expect(group.locator('[data-slot="basic-tool-tool-subtitle"]')).toHaveText("a.ts")
})
test("labels skill tools from IDs and result metadata", async ({ page }) => {
test("labels V2 skill tools from IDs and result metadata", async ({ page }) => {
const pending = "prt_skill_id"
const completed = "prt_skill_name"
await setupTimeline(page, {
@@ -131,10 +131,9 @@ test("labels skill tools from IDs and result metadata", async ({ page }) => {
"aria-label",
"sample-skill",
)
await expect(page.locator(`[data-timeline-part-id="${completed}"] [data-component="text-shimmer"]`)).toHaveAttribute(
"aria-label",
"OpenCode",
)
await expect(
page.locator(`[data-timeline-part-id="${completed}"] [data-component="text-shimmer"]`),
).toHaveAttribute("aria-label", "OpenCode")
for (const id of [pending, completed]) {
const skill = page.locator(`[data-timeline-part-id="${id}"]`)
await expect(skill.locator('[data-slot="skill-tool-label"]')).toHaveText("Skill")
@@ -153,7 +152,8 @@ function errorInput(tool: string) {
if (tool === "patch") return { patchText: "Update src/error.ts" }
if (tool === "webfetch") return { url: "https://example.com" }
if (tool === "websearch") return { query: "failure" }
if (tool === "subagent") return { description: "Fail subagent", agent: "explore", prompt: "Inspect the failure." }
if (tool === "subagent")
return { description: "Fail subagent", agent: "explore", prompt: "Inspect the failure." }
if (tool === "skill") return { name: "failure" }
return { target: "failure" }
}
@@ -87,7 +87,7 @@ test("reconnects after a stream error", async ({ page }) => {
expect((await timeline.transport.connections())[0]?.endedBy).toBe("error")
})
test("does not request replay when reconnecting the volatile event stream", async ({ page }) => {
test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
const timeline = await setupTimeline(page, { eventRetry: 10 })
const events = partUpdated(textPart("prt_transport_id", "event with id"))
const first = (
@@ -77,7 +77,7 @@ test("routes typing to the composer unless the open terminal is focused", async
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, "Terminal composer focus")
const composer = page.locator('[data-component="composer-editor"]')
const composer = page.locator('[data-component="prompt-input"]')
const terminal = page.locator('[data-component="terminal"]')
await page.keyboard.press("Control+Backquote")
await expect(terminal).toBeVisible()
@@ -116,7 +116,7 @@ test("keeps composer focus when a cached terminal finishes mounting", async ({ p
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`, { waitUntil: "commit" })
await expectSessionTitle(page, "Terminal composer focus")
const composer = page.locator('[data-component="composer-editor"]')
const composer = page.locator('[data-component="prompt-input"]')
const terminal = page.locator('[data-component="terminal"]')
await expect(terminal).toBeVisible()
expect(created.count).toBe(0)
@@ -142,7 +142,7 @@ test("keeps newer composer focus while an explicit terminal open finishes", asyn
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, "Terminal composer focus")
const composer = page.locator('[data-component="composer-editor"]')
const composer = page.locator('[data-component="prompt-input"]')
const terminal = page.locator('[data-component="terminal"]')
await page.keyboard.press("Control+Backquote")
await expect(terminal).toBeVisible()
@@ -187,7 +187,7 @@ test("focuses a terminal created from the new-terminal button", async ({ page })
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, "Terminal composer focus")
const composer = page.locator('[data-component="composer-editor"]')
const composer = page.locator('[data-component="prompt-input"]')
const terminal = page.locator('[data-component="terminal"]')
await page.keyboard.press("Control+Backquote")
await expect(terminal.locator("textarea")).toHaveCount(1)
@@ -1,7 +1,7 @@
import { createResource, createSignal, For, onMount, Suspense } from "solid-js"
import { render } from "solid-js/web"
import { createVirtualizer, observeElementOffset, observeElementRect } from "@tanstack/solid-virtual"
import { observeElementOffsetReconnectAware } from "../../../src/session/timeline/observe-element-offset"
import { observeElementOffsetReconnectAware } from "../../../src/pages/session/timeline/observe-element-offset"
const rowCount = 2_000
const rowHeight = 40
@@ -78,9 +78,9 @@ test("creates a session in a new project and selects its model", async ({ page }
await selectFolder.click()
await page.locator('[data-action="home-new-session"]').click()
await expectAppVisible(page.locator('[data-component="composer"]'))
await expectAppVisible(page.locator('[data-component="prompt-input-v2"]'))
const modelControl = page.locator('[data-action="composer-model"]')
const modelControl = page.locator('[data-action="prompt-model"]')
await modelControl.click()
await expect(page.locator('[data-option-key="opencode:free-model"]')).toBeVisible()
const goModel = page.locator('[data-option-key="opencode-go:go-model-1"]')
-249
View File
@@ -1,249 +0,0 @@
import { Schema, SchemaGetter } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"
const Json = Schema.Json.pipe(
Schema.decodeTo(Schema.Unknown, {
decode: SchemaGetter.passthrough(),
encode: SchemaGetter.transform(jsonValue),
}),
HttpApiSchema.asJson(),
)
const JsonPayload = Schema.Unknown.pipe(HttpApiSchema.asJson())
const Query = Schema.Struct({
directory: Schema.optional(Schema.String),
parentID: Schema.optional(Schema.String),
search: Schema.optional(Schema.String),
order: Schema.optional(Schema.String),
cursor: Schema.optional(Schema.String),
limit: Schema.optional(Schema.NumberFromString),
path: Schema.optional(Schema.String),
query: Schema.optional(Schema.String),
type: Schema.optional(Schema.String),
})
const SessionParams = { sessionID: Schema.String }
const NoContent = HttpApiSchema.NoContent
export class MockNotFound extends Schema.TaggedError<MockNotFound>()("MockNotFound", {
message: Schema.String,
}) {}
export class MockBadRequest extends Schema.TaggedError<MockBadRequest>()("MockBadRequest", {
message: Schema.String,
}) {}
const Group = HttpApiGroup.make("mock")
.add(HttpApiEndpoint.get("health", "/api/health", { success: Json }))
.add(
HttpApiEndpoint.get("event", "/api/event", {
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })),
}),
)
.add(HttpApiEndpoint.get("reference", "/api/reference", { success: Json }))
.add(HttpApiEndpoint.get("agent", "/api/agent", { success: Json }))
.add(HttpApiEndpoint.get("provider", "/api/provider", { success: Json }))
.add(HttpApiEndpoint.get("model", "/api/model", { success: Json }))
.add(HttpApiEndpoint.get("modelDefault", "/api/model/default", { success: Json }))
.add(HttpApiEndpoint.get("integrationList", "/api/integration", { success: Json }))
.add(
HttpApiEndpoint.get("integrationGet", "/api/integration/:integrationID", {
params: { integrationID: Schema.String },
success: Json,
}),
)
.add(
HttpApiEndpoint.post("integrationConnect", "/api/integration/:integrationID/connect/key", {
params: { integrationID: Schema.String },
payload: JsonPayload,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.delete("credentialRemove", "/api/credential/:credentialID", {
params: { credentialID: Schema.String },
success: NoContent,
}),
)
.add(HttpApiEndpoint.get("command", "/api/command", { success: Json }))
.add(HttpApiEndpoint.get("skill", "/api/skill", { success: Json }))
.add(HttpApiEndpoint.get("plugin", "/api/plugin", { success: Json }))
.add(HttpApiEndpoint.get("mcp", "/api/mcp", { success: Json }))
.add(HttpApiEndpoint.get("mcpResource", "/api/mcp/resource", { success: Json }))
.add(HttpApiEndpoint.get("projectList", "/api/project", { success: Json }))
.add(HttpApiEndpoint.get("projectCurrent", "/api/project/current", { success: Json }))
.add(
HttpApiEndpoint.get("worktreeList", "/api/worktree/:projectID", {
params: { projectID: Schema.String },
success: Json,
}),
)
.add(
HttpApiEndpoint.post("worktreeCreate", "/api/worktree/:projectID", {
params: { projectID: Schema.String },
payload: JsonPayload,
success: Json,
}),
)
.add(
HttpApiEndpoint.delete("worktreeRemove", "/api/worktree/:projectID", {
params: { projectID: Schema.String },
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("worktreeRefresh", "/api/worktree/:projectID/refresh", {
params: { projectID: Schema.String },
success: NoContent,
}),
)
.add(HttpApiEndpoint.get("location", "/api/location", { success: Json }))
.add(HttpApiEndpoint.get("permissionRequests", "/api/permission/request", { success: Json }))
.add(HttpApiEndpoint.get("formRequests", "/api/form/request", { success: Json }))
.add(HttpApiEndpoint.get("vcs", "/api/vcs", { success: Json }))
.add(HttpApiEndpoint.get("vcsStatus", "/api/vcs/status", { success: Json }))
.add(HttpApiEndpoint.get("vcsDiff", "/api/vcs/diff", { success: Json }))
.add(HttpApiEndpoint.get("fsList", "/api/fs/list", { query: Query, success: Json }))
.add(
HttpApiEndpoint.get("fsRead", "/api/fs/read/*", {
success: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),
}),
)
.add(HttpApiEndpoint.get("fsFind", "/api/fs/find", { query: Query, success: Json }))
.add(HttpApiEndpoint.get("shell", "/api/shell", { success: Json }))
.add(
HttpApiEndpoint.get("ptyConnectToken", "/api/pty/:ptyID/connect-token", {
params: { ptyID: Schema.String },
success: Json,
}),
)
.add(
HttpApiEndpoint.get("sessionList", "/api/session", {
query: Query,
success: Json,
error: MockBadRequest.pipe(HttpApiSchema.status(400)),
}),
)
.add(HttpApiEndpoint.post("sessionCreate", "/api/session", { payload: JsonPayload, success: Json }))
.add(HttpApiEndpoint.get("sessionActive", "/api/session/active", { success: Json }))
.add(
HttpApiEndpoint.get("sessionGet", "/api/session/:sessionID", {
params: SessionParams,
success: Json,
error: MockNotFound.pipe(HttpApiSchema.status(404)),
}),
)
.add(
HttpApiEndpoint.delete("sessionRemove", "/api/session/:sessionID", {
params: SessionParams,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionShell", "/api/session/:sessionID/shell", {
params: SessionParams,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.get("sessionForm", "/api/session/:sessionID/form", {
params: SessionParams,
success: Json,
}),
)
.add(
HttpApiEndpoint.post("sessionFormReply", "/api/session/:sessionID/form/:formID/reply", {
params: { ...SessionParams, formID: Schema.String },
payload: JsonPayload,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionFormCancel", "/api/session/:sessionID/form/:formID/cancel", {
params: { ...SessionParams, formID: Schema.String },
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionBackground", "/api/session/:sessionID/background", {
params: SessionParams,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.get("sessionInbox", "/api/session/:sessionID/inbox", {
params: SessionParams,
success: Json,
}),
)
.add(
HttpApiEndpoint.get("sessionPermission", "/api/session/:sessionID/permission", {
params: SessionParams,
success: Json,
}),
)
.add(
HttpApiEndpoint.post("sessionPermissionReply", "/api/session/:sessionID/permission/:permissionID/reply", {
params: { ...SessionParams, permissionID: Schema.String },
payload: JsonPayload,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionRename", "/api/session/:sessionID/rename", {
params: SessionParams,
payload: JsonPayload,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionInterrupt", "/api/session/:sessionID/interrupt", {
params: SessionParams,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionRevertStage", "/api/session/:sessionID/revert/stage", {
params: SessionParams,
payload: JsonPayload,
success: Json,
error: MockBadRequest.pipe(HttpApiSchema.status(400)),
}),
)
.add(
HttpApiEndpoint.post("sessionRevertClear", "/api/session/:sessionID/revert/clear", {
params: SessionParams,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionRevertCommit", "/api/session/:sessionID/revert/commit", {
params: SessionParams,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.get("messageGet", "/api/session/:sessionID/message/:messageID", {
params: { ...SessionParams, messageID: Schema.String },
success: Json,
error: MockNotFound.pipe(HttpApiSchema.status(404)),
}),
)
.add(
HttpApiEndpoint.get("messageList", "/api/session/:sessionID/message", {
params: SessionParams,
query: Query,
success: Json,
error: MockBadRequest.pipe(HttpApiSchema.status(400)),
}),
)
export const MockApi = HttpApi.make("mock").add(Group)
function jsonValue(value: unknown): Schema.Json {
if (value === null || typeof value === "string" || typeof value === "boolean") return value
if (typeof value === "number") return Number.isFinite(value) ? value : null
if (Array.isArray(value)) return value.map(jsonValue)
if (!value || typeof value !== "object") return null
return Object.fromEntries(
Object.entries(value).flatMap(([key, item]) => (item === undefined ? [] : [[key, jsonValue(item)]])),
)
}
+311 -318
View File
@@ -1,9 +1,5 @@
import type { Page } from "@playwright/test"
import type { Page, Route } from "@playwright/test"
import type { JsonValue, OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client/promise"
import { Duration, Effect, Layer } from "effect"
import { HttpRouter, HttpServer, HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { MockApi, MockBadRequest, MockNotFound } from "./mock-api"
export interface MockServerConfig {
provider: unknown | (() => unknown)
@@ -26,7 +22,6 @@ export interface MockServerConfig {
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
message?: (sessionID: string, messageID: string) => SessionMessageInfo | undefined
onMessage?: (input: { sessionID: string; messageID: string }) => void
onRevertStage?: (input: { sessionID: string; messageID: string }) => void
events?: () => OpenCodeEvent[]
eventRetry?: number
permissions?: unknown[] | (() => unknown[])
@@ -43,8 +38,9 @@ type MockStreamWindow = Window & {
}
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const state = { cursors: new Map<string, string>(), nextCursor: 0 }
const cursors = new Map<string, string>()
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
let nextCursor = 0
await page.addInitScript(
({ server, retry }) => {
@@ -131,331 +127,307 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
}, 50)
page.on("close", () => clearInterval(timer))
}
const transport = HttpRouter.toWebHandler(
HttpApiBuilder.layer(MockApi).pipe(
Layer.provide(mockHandlers(config, state)),
Layer.provide(HttpServer.layerServices),
),
{ disableLogger: true },
)
page.on("close", () => void transport.dispose())
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
const appPort = new URL(
process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${process.env.PLAYWRIGHT_PORT ?? "3000"}`,
).port
if (url.origin !== server && url.port !== appPort) return route.fallback()
if (route.request().method() === "OPTIONS") {
return route.fulfill({ status: 204, headers: corsHeaders })
}
const body = route.request().postDataBuffer()
const response = await transport.handler(
new Request(url, {
method: route.request().method(),
headers: route.request().headers(),
body: body ? Uint8Array.from(body) : undefined,
}),
)
if (response.status === 404 && url.origin !== server) return route.fallback()
return route.fulfill({
status: response.status,
headers: { ...Object.fromEntries(response.headers), ...corsHeaders },
body: Buffer.from(await response.arrayBuffer()),
})
})
}
const corsHeaders = {
"access-control-allow-origin": "*",
"access-control-allow-headers": "*",
"access-control-allow-methods": "GET, POST, PUT, DELETE, OPTIONS",
"access-control-expose-headers": "x-next-cursor",
}
function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, string>; nextCursor: number }) {
const noContent = Effect.succeed(HttpApiSchema.NoContent.make())
const delay = config.messageDelay === undefined ? Effect.void : Effect.sleep(Duration.millis(config.messageDelay))
return HttpApiBuilder.group(MockApi, "mock", (handlers) =>
handlers
.handleRaw("event", () => {
const events = config.events?.()
const retry = config.eventRetry === undefined ? "" : `retry: ${config.eventRetry}\n\n`
const body = [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events ?? [])]
.map((event) => `data: ${JSON.stringify(event)}\n\n`)
.join("")
return Effect.succeed(HttpServerResponse.text(retry + body, { contentType: "text/event-stream" }))
})
.handleRaw("fsRead", (ctx) =>
Effect.gen(function* () {
const path = decodeURIComponent(new URL(ctx.request.url, "http://localhost").pathname.slice(13))
const value = yield* Effect.promise(() => Promise.resolve(config.fileContent?.(path)))
const content =
value && typeof value === "object" && "content" in value ? String(value.content) : String(value ?? "")
return HttpServerResponse.uint8Array(new TextEncoder().encode(content))
}),
const path = url.pathname
if (path === "/api/event") {
const events = config.events?.()
return sse(
route,
[{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events ?? [])],
config.eventRetry,
)
.handleAll({
health: () => Effect.succeed({ healthy: true, version: "2.0.0", pid: 1 }),
reference: () =>
Effect.succeed({
location: {
directory: config.directory,
project: {
id: (config.project as { id?: string }).id,
directory: config.directory,
canonical: config.directory,
},
},
data: [],
}),
agent: () =>
Effect.succeed({
location: location(config),
data: [
{
id: "build",
name: "Build",
mode: "primary",
hidden: false,
request: { settings: {}, headers: {}, body: {} },
permissions: [],
},
],
}),
provider: () => Effect.succeed({ location: location(config), data: currentProviders(providerConfig(config)) }),
model: () => Effect.succeed({ location: location(config), data: currentModels(providerConfig(config)) }),
modelDefault: () =>
Effect.succeed({ location: location(config), data: currentDefaultModel(providerConfig(config)) }),
integrationList: () => Effect.succeed({ location: location(config), data: [] }),
integrationGet: (ctx) =>
Effect.succeed({
location: location(config),
data: {
id: ctx.params.integrationID,
name: ctx.params.integrationID,
methods: config.integrationMethods?.[ctx.params.integrationID] ?? [{ type: "key", label: "API key" }],
connections: [],
},
}),
integrationConnect: (ctx) =>
Effect.sync(() => config.onConnectKey?.({ integrationID: ctx.params.integrationID, body: ctx.payload })).pipe(
Effect.andThen(noContent),
),
credentialRemove: () => noContent,
command: () => Effect.succeed({ location: location(config), data: [] }),
skill: () => Effect.succeed({ location: location(config), data: [] }),
plugin: () => Effect.succeed({ location: location(config), data: [] }),
mcp: () => Effect.succeed({ location: location(config), data: [] }),
mcpResource: () => Effect.succeed({ location: location(config), data: { resources: [], templates: [] } }),
projectList: () => {
const project = config.project as typeof config.project & { canonical?: string; worktree?: string }
return Effect.succeed([{ ...project, canonical: project.canonical ?? project.worktree ?? config.directory }])
},
projectCurrent: () =>
Effect.succeed({
}
if (path === "/api/health") return json(route, { healthy: true, version: "2.0.0", pid: 1 })
if (path === "/api/reference")
return json(route, {
location: {
directory: config.directory,
project: {
id: (config.project as { id?: string }).id,
directory: config.directory,
canonical: config.directory,
}),
worktreeList: () =>
Effect.succeed([
{ directory: config.directory },
...((config.project as { sandboxes?: string[] }).sandboxes ?? []).map((directory) => ({
directory,
strategy: "git",
})),
]),
worktreeCreate: (ctx) => {
const input = record(ctx.payload) ? ctx.payload : {}
return Effect.succeed({
directory: `${typeof input.directory === "string" ? input.directory : config.directory}/${
typeof input.name === "string" ? input.name : "copy"
}`,
})
},
},
worktreeRemove: () => noContent,
worktreeRefresh: () => noContent,
location: () => Effect.succeed(location(config)),
permissionRequests: () =>
Effect.succeed({
location: location(config),
data: (typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])).map(
currentPermission,
),
}),
formRequests: () =>
Effect.succeed({
location: location(config),
data: typeof config.forms === "function" ? config.forms() : (config.forms ?? []),
}),
vcs: () =>
Effect.succeed({ location: location(config), data: { branch: { current: "main", default: "main" } } }),
vcsStatus: () => Effect.succeed({ location: location(config), data: [] }),
vcsDiff: () => Effect.succeed({ location: location(config), data: config.vcsDiff ?? [] }),
fsList: (ctx) =>
Effect.promise(() => Promise.resolve(config.fileList?.(ctx.query.path ?? ""))).pipe(
Effect.map((data) => ({ location: location(config), data })),
),
fsFind: (ctx) =>
Effect.promise(() =>
Promise.resolve(
config.findFiles?.({ query: ctx.query.query ?? "", dirs: ctx.query.type, limit: ctx.query.limit }),
),
).pipe(
Effect.map((entries) => ({
location: location(config),
data: Array.isArray(entries)
? entries.map((entry) =>
typeof entry === "string"
? {
name: entry.split(/[\\/]/).at(-1) ?? entry,
path: entry,
absolute: `${config.directory}/${entry}`,
type: "directory",
ignored: false,
}
: entry,
)
: entries,
})),
),
shell: () => Effect.succeed({ location: location(config), data: [] }),
ptyConnectToken: () =>
Effect.succeed({ location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } }),
sessionList: (ctx) => {
const sessions = config.sessions
.filter((session) => {
const location = session.location as { directory?: string } | undefined
return (
!ctx.query.directory ||
location?.directory === ctx.query.directory ||
session.directory === ctx.query.directory
)
})
.filter((session) => {
if (ctx.query.parentID === undefined) return true
if (ctx.query.parentID === "null") return session.parentID === undefined
return session.parentID === ctx.query.parentID
})
.filter((session) =>
ctx.query.search === undefined
? true
: String(session.title ?? "")
.toLowerCase()
.includes(ctx.query.search.toLowerCase()),
data: [],
})
if (path === "/api/agent")
return json(route, {
location: location(config),
data: [
{
id: "build",
name: "Build",
mode: "primary",
hidden: false,
request: { settings: {}, headers: {}, body: {} },
permissions: [],
},
],
})
if (path === "/api/provider")
return json(route, {
location: location(config),
data: currentProviders(providerConfig(config)),
})
if (path === "/api/model")
return json(route, { location: location(config), data: currentModels(providerConfig(config)) })
if (path === "/api/model/default")
return json(route, { location: location(config), data: currentDefaultModel(providerConfig(config)) })
if (path === "/api/integration") return json(route, { location: location(config), data: [] })
if (path === "/api/command") return json(route, { location: location(config), data: [] })
if (path === "/api/skill") return json(route, { location: location(config), data: [] })
if (path === "/api/plugin") return json(route, { location: location(config), data: [] })
if (path === "/api/mcp") return json(route, { location: location(config), data: [] })
if (path === "/api/mcp/resource")
return json(route, { location: location(config), data: { resources: [], templates: [] } })
const integration = path.match(/^\/api\/integration\/([^/]+)$/)?.[1]
if (integration && route.request().method() === "GET")
return json(route, {
location: location(config),
data: {
id: integration,
name: integration,
methods: config.integrationMethods?.[integration] ?? [{ type: "key", label: "API key" }],
connections: [],
},
})
const integrationConnect = path.match(/^\/api\/integration\/([^/]+)\/connect\/key$/)?.[1]
if (integrationConnect && route.request().method() === "POST") {
config.onConnectKey?.({ integrationID: integrationConnect, body: route.request().postDataJSON() })
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (/^\/api\/credential\/[^/]+$/.test(path) && route.request().method() === "DELETE")
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (path === "/api/project") {
const project = config.project as typeof config.project & { canonical?: string; worktree?: string }
return json(route, [
{
...project,
canonical: project.canonical ?? project.worktree ?? config.directory,
},
])
}
if (path === "/api/project/current")
return json(route, {
id: (config.project as { id?: string }).id,
directory: config.directory,
canonical: config.directory,
})
const worktree = path.match(/^\/api\/worktree\/([^/]+)$/)?.[1]
if (worktree && route.request().method() === "GET")
return json(route, [
{ directory: config.directory },
...((config.project as { sandboxes?: string[] }).sandboxes ?? []).map((directory) => ({
directory,
strategy: "git",
})),
])
if (path === "/api/location") return json(route, location(config))
if (worktree && route.request().method() === "POST") {
const input = route.request().postDataJSON() as { directory: string; name?: string }
return json(route, { directory: `${input.directory}/${input.name ?? "copy"}` })
}
if (worktree && route.request().method() === "DELETE")
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (/^\/api\/worktree\/[^/]+\/refresh$/.test(path))
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (path === "/api/permission/request")
return json(route, {
location: location(config),
data: (typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])).map(
currentPermission,
),
})
if (path === "/api/form/request")
return json(route, {
location: location(config),
data: typeof config.forms === "function" ? config.forms() : (config.forms ?? []),
})
if (path === "/api/vcs")
return json(route, { location: location(config), data: { branch: { current: "main", default: "main" } } })
if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] })
if (path === "/api/vcs/diff") return json(route, { location: location(config), data: config.vcsDiff ?? [] })
if (path === "/api/fs/list" && config.fileList)
return json(route, {
location: location(config),
data: await config.fileList(url.searchParams.get("path") ?? ""),
})
const fileRead = path.match(/^\/api\/fs\/read\/(.+)$/)?.[1]
if (fileRead && config.fileContent) {
const value = await config.fileContent(decodeURIComponent(fileRead))
const content =
value && typeof value === "object" && "content" in value ? String(value.content) : String(value ?? "")
return route.fulfill({ status: 200, body: content, headers: { "content-type": "application/octet-stream" } })
}
if (path === "/api/fs/find" && config.findFiles) {
const entries = await config.findFiles({
query: url.searchParams.get("query") ?? "",
dirs: url.searchParams.get("type") ?? undefined,
limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined,
})
return json(route, {
location: location(config),
data: Array.isArray(entries)
? entries.map((entry) =>
typeof entry === "string"
? {
name: entry.split(/[\\/]/).at(-1) ?? entry,
path: entry,
absolute: `${config.directory}/${entry}`,
type: "directory",
ignored: false,
}
: entry,
)
const ordered = ctx.query.order === "asc" ? sessions : sessions.toReversed()
const offset = Number(ctx.query.cursor ?? 0)
const limit = ctx.query.limit ?? 50
const data = ordered.slice(offset, offset + limit)
return Effect.succeed({
data: data.map((session) => currentSession(session, config.directory)),
cursor: { next: offset + limit < ordered.length ? String(offset + limit) : undefined },
})
},
sessionCreate: (ctx) => {
const payload = record(ctx.payload) ? ctx.payload : {}
const created = currentSession(
{
id: "ses_mock_created",
projectID: (config.project as { id?: string }).id,
title: typeof payload.title === "string" ? payload.title : "New session",
parentID: typeof payload.parentID === "string" ? payload.parentID : undefined,
},
config.directory,
: entries,
})
}
if (path === "/api/shell" && route.request().method() === "GET")
return json(route, { location: location(config), data: [] })
if (/^\/api\/pty\/[^/]+\/connect-token$/.test(path))
return json(route, { location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } })
if (path === "/api/session") {
if (route.request().method() === "POST") {
const payload = route.request().postDataJSON() as Record<string, unknown>
const created = currentSession(
{
id: "ses_mock_created",
projectID: (config.project as { id?: string }).id,
title: typeof payload.title === "string" ? payload.title : "New session",
parentID: typeof payload.parentID === "string" ? payload.parentID : undefined,
},
config.directory,
)
config.sessions.push(created)
return json(route, { data: created })
}
if (route.request().method() !== "GET") return route.fallback()
const directory = url.searchParams.get("directory")
const parentID = url.searchParams.get("parentID")
const limit = Number(url.searchParams.get("limit") ?? 50)
const offset = Number(url.searchParams.get("cursor") ?? 0)
const sessions = config.sessions
.filter((session) => {
const location = session.location as { directory?: string } | undefined
return !directory || location?.directory === directory || session.directory === directory
})
.filter((session) => {
if (parentID === null) return true
if (parentID === "null") return session.parentID === undefined
return session.parentID === parentID
})
.filter((session) => {
const search = url.searchParams.get("search")?.toLowerCase()
return (
!search ||
String(session.title ?? "")
.toLowerCase()
.includes(search)
)
return Effect.sync(() => config.sessions.push(created)).pipe(Effect.as({ data: created }))
},
sessionActive: () => {
const statuses = (
typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {})
) as Record<string, { type?: string }>
return Effect.succeed({
data: Object.fromEntries(
Object.entries(statuses).flatMap(([id, status]) =>
status.type === "idle" ? [] : [[id, { type: "running" }]],
),
),
})
},
sessionGet: (ctx) => {
const session = config.sessions.find((item) => item.id === ctx.params.sessionID)
return session
? Effect.succeed({ data: currentSession(session, config.directory) })
: Effect.fail(new MockNotFound({ message: "Session not found" }))
},
sessionRemove: () => noContent,
sessionShell: () => noContent,
sessionForm: (ctx) => {
const forms = typeof config.forms === "function" ? config.forms() : (config.forms ?? [])
return Effect.succeed({
data: forms.filter((form) => (form as { sessionID?: string }).sessionID === ctx.params.sessionID),
})
},
sessionFormReply: () => noContent,
sessionFormCancel: () => noContent,
sessionBackground: () => noContent,
sessionInbox: () => Effect.succeed({ data: [] }),
sessionPermission: (ctx) => {
const permissions =
typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])
return Effect.succeed({
data: permissions
.map(currentPermission)
.filter((permission) => permission.sessionID === ctx.params.sessionID),
})
},
sessionPermissionReply: () => noContent,
sessionRename: () => noContent,
sessionInterrupt: () => noContent,
sessionRevertStage: (ctx) => {
const payload = record(ctx.payload) ? ctx.payload : {}
const messageID = payload.messageID
if (typeof messageID !== "string") {
return Effect.fail(new MockBadRequest({ message: "Invalid revert request" }))
}
return Effect.sync(() => config.onRevertStage?.({ sessionID: ctx.params.sessionID, messageID })).pipe(
Effect.as({ data: { messageID } }),
)
},
sessionRevertClear: () => noContent,
sessionRevertCommit: () => noContent,
messageGet: (ctx) =>
Effect.gen(function* () {
config.onMessage?.({ sessionID: ctx.params.sessionID, messageID: ctx.params.messageID })
yield* delay
const message =
config.message?.(ctx.params.sessionID, ctx.params.messageID) ??
config
.pageMessages(ctx.params.sessionID, Number.MAX_SAFE_INTEGER)
.items.find((item) => item.id === ctx.params.messageID)
if (!message) return yield* new MockNotFound({ message: "Message not found" })
return { data: message }
}),
messageList: (ctx) => {
const token = ctx.query.cursor
const before = token ? state.cursors.get(token) : undefined
if (token && !before) return Effect.fail(new MockBadRequest({ message: "Invalid cursor" }))
return Effect.gen(function* () {
config.onMessages?.({ sessionID: ctx.params.sessionID, before, phase: "start" })
if (config.beforeMessagesResponse) {
yield* Effect.promise(() => config.beforeMessagesResponse!({ sessionID: ctx.params.sessionID, before }))
}
yield* delay
const pageData = config.pageMessages(ctx.params.sessionID, ctx.query.limit ?? 50, before)
config.onMessages?.({ sessionID: ctx.params.sessionID, before, phase: "end" })
const cursor = pageData.cursor ? `cursor_${++state.nextCursor}` : undefined
if (cursor) state.cursors.set(cursor, pageData.cursor!)
return {
data: ctx.query.order === "asc" ? pageData.items : pageData.items.toReversed(),
cursor: { next: cursor },
}
})
},
}),
)
})
const ordered = url.searchParams.get("order") === "asc" ? sessions : sessions.toReversed()
const data = ordered.slice(offset, offset + limit)
const next = offset + limit < ordered.length ? String(offset + limit) : undefined
return json(route, {
data: data.map((session) => currentSession(session, config.directory)),
cursor: { next },
})
}
if (path === "/api/session/active") {
const statuses = (
typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {})
) as Record<string, { type?: string }>
return json(route, {
data: Object.fromEntries(
Object.entries(statuses).flatMap(([id, status]) =>
status.type === "idle" ? [] : [[id, { type: "running" }]],
),
),
})
}
if (/^\/api\/session\/[^/]+\/shell$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
const sessionForm = path.match(/^\/api\/session\/([^/]+)\/form$/)?.[1]
if (sessionForm && route.request().method() === "GET") {
const forms = typeof config.forms === "function" ? config.forms() : (config.forms ?? [])
return json(route, { data: forms.filter((form) => (form as { sessionID?: string }).sessionID === sessionForm) })
}
if (/^\/api\/session\/[^/]+\/form\/[^/]+\/(reply|cancel)$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (/^\/api\/session\/[^/]+\/background$/.test(path) && route.request().method() === "POST")
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (/^\/api\/session\/[^/]+\/inbox$/.test(path) && route.request().method() === "GET")
return json(route, { data: [] })
const sessionPermission = path.match(/^\/api\/session\/([^/]+)\/permission$/)?.[1]
if (sessionPermission && route.request().method() === "GET") {
const permissions = typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])
return json(route, {
data: permissions.map(currentPermission).filter((permission) => permission.sessionID === sessionPermission),
})
}
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (
/^\/api\/session\/[^/]+\/(rename|interrupt|revert\/clear|revert\/commit)$/.test(path) &&
route.request().method() === "POST"
) {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/)
if (currentSessionMatch) {
const session = config.sessions.find((item) => item.id === currentSessionMatch[1])
if (!session) return json(route, { error: "Session not found" }, undefined, 404)
return json(route, {
data: currentSession(session, config.directory),
})
}
const messageMatch = path.match(/^\/api\/session\/([^/]+)\/message\/([^/]+)$/)
if (messageMatch) {
config.onMessage?.({ sessionID: messageMatch[1]!, messageID: messageMatch[2]! })
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
const message =
config.message?.(messageMatch[1]!, messageMatch[2]!) ??
config.pageMessages(messageMatch[1]!, Number.MAX_SAFE_INTEGER).items.find((item) => item.id === messageMatch[2])
if (message === undefined) return json(route, { error: "Message not found" }, undefined, 404)
return json(route, { data: message })
}
const messagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/)
if (messagesMatch) {
const token = url.searchParams.get("cursor") ?? undefined
const before = token ? cursors.get(token) : undefined
if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" })
await config.beforeMessagesResponse?.({ sessionID: messagesMatch[1]!, before })
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
const pageData = config.pageMessages(messagesMatch[1], Number(url.searchParams.get("limit") ?? 50), before)
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" })
const cursor = pageData.cursor ? `cursor_${++nextCursor}` : undefined
if (cursor) cursors.set(cursor, pageData.cursor!)
return json(route, {
data: url.searchParams.get("order") === "asc" ? pageData.items : pageData.items.toReversed(),
cursor: { next: cursor },
})
}
if (url.port === targetPort && targetPort !== appPort)
return json(route, { error: `Unhandled mock route: ${path}` }, undefined, 404)
return route.fallback()
})
}
function location(config: MockServerConfig) {
@@ -613,3 +585,24 @@ function jsonValue(value: unknown): JsonValue | undefined {
function record(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}
function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) {
return route.fulfill({
status,
contentType: "application/json",
headers: {
"access-control-allow-origin": "*",
"access-control-expose-headers": "x-next-cursor",
...headers,
},
body: JSON.stringify(body ?? null),
})
}
function sse(route: Route, events?: unknown[], retry?: number) {
return route.fulfill({
status: 200,
contentType: "text/event-stream",
body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`,
})
}
+9 -11
View File
@@ -5,11 +5,10 @@
"type": "module",
"exports": {
".": "./src/index.ts",
"./desktop": "./src/desktop.ts",
"./desktop-menu": "./src/shell/commands/desktop-menu.ts",
"./i18n/desktop-native": "./src/runtime/i18n/desktop-native.ts",
"./updater": "./src/shell/updates/types.ts",
"./wsl/types": "./src/servers/wsl/types.ts",
"./desktop-menu": "./src/desktop-menu.ts",
"./i18n/desktop-native": "./src/i18n/desktop-native.ts",
"./updater": "./src/updater.ts",
"./wsl/types": "./src/wsl/types.ts",
"./vite": "./vite.js",
"./index.css": "./src/index.css"
},
@@ -29,15 +28,14 @@
"test:e2e:ui": "playwright test --ui",
"test:e2e:report": "playwright show-report e2e/playwright-report",
"test:stability": "bun test ./e2e/performance/unit/visual-stability.test.ts && playwright test --config e2e/performance/timeline-stability/playwright.config.ts",
"test:bench": "bun test ./e2e/performance/unit && playwright test --config e2e/performance/playwright.config.ts",
"test:bench:devex": "bun test ./e2e/performance/unit/desktop-startup.test.ts && playwright test --config e2e/performance/devex/playwright.config.ts"
"test:bench": "bun test ./e2e/performance/unit && playwright test --config e2e/performance/playwright.config.ts"
},
"license": "MIT",
"devDependencies": {
"@happy-dom/global-registrator": "20.0.11",
"@playwright/test": "catalog:",
"@sentry/vite-plugin": "catalog:",
"@tailwindcss/vite": "4.3.3",
"@tailwindcss/vite": "catalog:",
"@tsconfig/bun": "1.0.9",
"@types/bun": "catalog:",
"@types/luxon": "catalog:",
@@ -46,9 +44,9 @@
"happy-dom": "20.11.1",
"tw-animate-css": "1.4.0",
"typescript": "catalog:",
"vite": "8.2.1",
"vite": "catalog:",
"vite-plugin-icons-spritesheet": "3.0.1",
"vite-plugin-solid": "2.11.14"
"vite-plugin-solid": "catalog:"
},
"dependencies": {
"@corvu/drawer": "catalog:",
@@ -87,6 +85,6 @@
"solid-js": "catalog:",
"solid-list": "catalog:",
"solid-presence": "0.2.0",
"tailwindcss": "4.3.3"
"tailwindcss": "catalog:"
}
}
+152 -18
View File
@@ -1,29 +1,109 @@
import "@/index.css"
import * as Sentry from "@sentry/solid"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { File } from "@opencode-ai/session-ui/file"
import { Font } from "@opencode-ai/ui/font"
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Router } from "@solidjs/router"
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { type Component, createRenderEffect, ErrorBoundary, type JSX, type ParentProps } from "solid-js"
import {
type Component,
createMemo,
createRenderEffect,
ErrorBoundary,
type JSX,
lazy,
type ParentProps,
Show,
} from "solid-js"
import { Dynamic } from "solid-js/web"
import { CommandProvider } from "@/shell/commands/command"
import { DesktopCommands } from "@/shell/commands/desktop"
import { GlobalProvider } from "@/runtime/server/runtime"
import { HighlightsProvider } from "@/shell/updates/highlights"
import { LanguageProvider, UiI18nBridge, type Locale } from "@/runtime/i18n/language"
import { ServerConnection, ServersProvider } from "@/runtime/server/registry"
import { SettingsProvider } from "@/settings/model"
import { TabsProvider } from "@/shell/tabs/tabs"
import { WslServersProvider } from "@/servers/wsl/context"
import { ErrorPage } from "@/shell/errors/error"
import { AppRoutes, File, preloadRoute } from "@/shell/routes/routes"
import { CommandProvider, useCommand, type CommandOption } from "@/context/command"
import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file"
import { GlobalProvider, useGlobal } from "@/context/global"
import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, UiI18nBridge, type Locale, useLanguage } from "@/context/language"
import { LayoutProvider } from "@/context/layout"
import { ModelsProvider } from "@/context/models"
import { usePlatform } from "@/context/platform"
import { PromptProvider } from "@/context/prompt"
import { ServerConnection, ServersProvider } from "@/context/servers"
import { SettingsProvider } from "@/context/settings"
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
import { LocationProvider } from "@/context/location"
import { WslServersProvider } from "@/wsl/context"
import { SessionUIProvider } from "@/pages/directory-layout"
import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error"
import { requireServerKey } from "./utils/session-route"
export { preloadRoute }
import { TargetSessionRouteContent } from "@/pages/session"
import { Home } from "@/pages/home"
import { ServerProvider } from "./context/server"
const NewSession = lazy(() => import("@/pages/new-session"))
function TargetServerRoute(props: ParentProps) {
const params = useParams<{ serverKey: string }>()
const global = useGlobal()
const conn = createMemo(() =>
global.servers.list().find((item) => ServerConnection.key(item) === requireServerKey(params.serverKey)),
)
return (
// Owns the server-identity remount. Session changes must not remount this subtree.
<Show when={conn()} keyed>
{(conn) => <ServerProvider conn={conn}>{props.children}</ServerProvider>}
</Show>
)
}
function DraftRoute() {
const [search] = useSearchParams<{ draftId?: string }>()
const tabs = useTabs()
return (
<Show
when={tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)}
keyed
fallback={tabs.ready() && <Navigate href="/" />}
>
{(draft) => <ResolvedDraftRoute draft={draft} />}
</Show>
)
}
function ResolvedDraftRoute(props: { draft: DraftTab }) {
const global = useGlobal()
const conn = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === props.draft.server))
return (
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
<Show when={conn()} keyed>
{(conn) => (
<ServerProvider conn={conn}>
<ModelsProvider directory={props.draft.directory}>
<LocationProvider directory={props.draft.directory}>
<SessionUIProvider directory={props.draft.directory} server={props.draft.server}>
<DraftProviders>
<NewSession draftId={props.draft.draftID} />
</DraftProviders>
</SessionUIProvider>
</LocationProvider>
</ModelsProvider>
</ServerProvider>
)}
</Show>
</Show>
)
}
declare global {
interface Window {
__OPENCODE__?: {
deepLinks?: string[]
}
api?: {
setTitlebar?: (theme: { mode: "light" | "dark"; scheme?: "system" | "light" | "dark" }) => Promise<void>
exportDebugLogs?: () => Promise<string>
@@ -54,11 +134,55 @@ function BodyTypography() {
return null
}
// Server-agnostic providers shared across every route. These live in the shared
// shell (router root) so they stay mounted regardless of the active server/route.
function DesktopCommands() {
const command = useCommand()
const language = useLanguage()
const platform = usePlatform()
command.register("desktop", () => {
const commands: CommandOption[] = []
if (platform.platform === "desktop" && platform.exportDebugLogs) {
commands.push({
id: "logs.export",
title: language.t("command.logs.export"),
category: language.t("command.category.settings"),
onSelect: () => {
void platform.exportDebugLogs?.()
},
})
}
return commands
})
return null
}
function AppLayout(props: ParentProps) {
return (
<LayoutProvider>
<Layout>{props.children}</Layout>
</LayoutProvider>
)
}
// The draft page only renders the prompt composer, so it drops TerminalProvider.
// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
function DraftProviders(props: ParentProps) {
return (
<FileProvider>
<PromptProvider>
<CommentsProvider>{props.children}</CommentsProvider>
</PromptProvider>
</FileProvider>
)
}
export function AppBaseProviders(
props: ParentProps<{
locale?: Locale
onNativeTranslations?: Parameters<typeof LanguageProvider>[0]["onNativeTranslations"]
onThemeApplied?: () => void
}>,
) {
return (
@@ -67,14 +191,13 @@ export function AppBaseProviders(
<ThemeProvider
onThemeApplied={(_, mode, scheme) => {
void window.api?.setTitlebar?.({ mode, scheme })
props.onThemeApplied?.()
}}
>
<LanguageProvider locale={props.locale} onNativeTranslations={props.onNativeTranslations}>
<UiI18nBridge>
<ErrorBoundary
fallback={(error) => {
void import("@sentry/solid").then(({ captureException }) => captureException(error))
Sentry.captureException(error)
return <ErrorPage error={error} />
}}
>
@@ -125,7 +248,18 @@ export function AppInterface(props: {
<SettingsProvider>
<GlobalProvider>
<Dynamic component={props.router ?? Router} root={Root}>
<AppRoutes />
<Route component={AppLayout}>
<Route path="/" component={Home} />
<Route
path="/server/:serverKey/session/:id"
component={() => (
<TargetServerRoute>
<TargetSessionRouteContent />
</TargetServerRoute>
)}
/>
<Route path="/new-session" component={DraftRoute} />
</Route>
</Dynamic>
</GlobalProvider>
</SettingsProvider>
@@ -1,20 +1,20 @@
import { getFilename } from "@opencode-ai/util/path"
import type { Project } from "@/runtime/server/types"
import type { Project } from "@/types"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createMemo, onCleanup } from "solid-js"
import { commandPaletteOptions, useCommand, type CommandOption } from "@/shell/commands/command"
import { useFile } from "@/workspaces/files/model"
import { useGlobal } from "@/runtime/server/runtime"
import { useLanguage } from "@/runtime/i18n/language"
import { useLayout, type LocalProject } from "@/shell/state/layout"
import { ServerConnection } from "@/runtime/server/registry"
import { useServerSDK } from "@/runtime/server/client"
import { useTabs } from "@/shell/tabs/tabs"
import { displayName, projectForSession } from "@/shell/layout/helpers"
import { createSessionTabs } from "@/session/helpers"
import { useSessionLayout } from "@/session/session-layout"
import { useServer } from "@/runtime/server/current"
import { commandPaletteOptions, useCommand, type CommandOption } from "@/context/command"
import { useFile } from "@/context/file"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { useLayout, type LocalProject } from "@/context/layout"
import { ServerConnection } from "@/context/servers"
import { useServerSDK } from "@/context/server-sdk"
import { useTabs } from "@/context/tabs"
import { displayName, projectForSession } from "@/pages/layout/helpers"
import { createSessionTabs } from "@/pages/session/helpers"
import { useSessionLayout } from "@/pages/session/session-layout"
import { useServer } from "@/context/server"
export type CommandPaletteEntry = {
id: string
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { newTabTooltipKeybind, reviewTooltipKeybind } from "./tooltip-keybind"
import { newTabTooltipKeybind, reviewTooltipKeybind } from "./command-tooltip-keybind"
describe("command tooltip keybinds", () => {
test("keeps localized review shortcut modifiers", () => {
@@ -3,8 +3,8 @@ import { batch, createEffect, onCleanup, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
type Mem = Performance & {
memory?: {
@@ -1,14 +1,14 @@
.command-palette {
.command-palette-v2 {
overflow: hidden;
}
/* Anchor to the top edge of where a centered 480px-tall dialog would sit, so the
top stays put while the content-driven height grows and shrinks. */
[data-component="dialog-v2"]:has(.command-palette) {
[data-component="dialog-v2"]:has(.command-palette-v2) {
align-items: flex-start;
}
[data-component="dialog-v2"]:has(.command-palette) [data-slot="dialog-container"] {
[data-component="dialog-v2"]:has(.command-palette-v2) [data-slot="dialog-container"] {
width: min(calc(100vw - 24px), 640px);
height: auto;
min-height: 280px;
@@ -19,7 +19,7 @@
box-shadow: var(--v2-elevation-floating);
}
.command-palette-body {
.command-palette-v2-body {
display: flex;
min-height: 0;
flex: 1;
@@ -28,12 +28,12 @@
padding: 0;
}
.command-palette-search {
.command-palette-v2-search {
flex-shrink: 0;
padding: 6px;
}
.command-palette-search [data-component="text-input-v2"] {
.command-palette-v2-search [data-component="text-input-v2"] {
width: 100%;
height: 36px;
border-radius: 6px;
@@ -45,46 +45,46 @@
box-shadow 120ms ease-in-out;
}
.command-palette-search [data-component="text-input-v2"]:where(:hover):not([data-disabled], [data-invalid]),
.command-palette-search [data-component="text-input-v2"]:where(:focus-within):not([data-disabled], [data-invalid]) {
.command-palette-v2-search [data-component="text-input-v2"]:where(:hover):not([data-disabled], [data-invalid]),
.command-palette-v2-search [data-component="text-input-v2"]:where(:focus-within):not([data-disabled], [data-invalid]) {
background: var(--v2-background-bg-layer-02);
box-shadow: none;
}
.command-palette-search [data-component="text-input-v2"] [data-slot="text-input-v2-value"] {
.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-value"] {
border-radius: 6px;
background: transparent;
gap: 8px;
}
.command-palette-search [data-component="text-input-v2"] [data-slot="text-input-v2-leading-icon"] {
.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-leading-icon"] {
padding-left: 12px;
}
.command-palette-search [data-component="text-input-v2"] [data-slot="text-input-v2-input"] {
.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-input"] {
border-radius: 6px;
background: transparent;
}
.command-palette-scroll {
.command-palette-v2-scroll {
min-height: 0;
flex: 1;
}
.command-palette-results {
.command-palette-v2-results {
display: flex;
flex-direction: column;
gap: 16px;
padding: 6px 6px 8px;
}
.command-palette-group {
.command-palette-v2-group {
display: flex;
flex-direction: column;
gap: 1px;
}
.command-palette-group-title {
.command-palette-v2-group-title {
margin: 6px 0;
padding: 0 12px;
color: var(--v2-text-text-muted);
@@ -95,7 +95,7 @@
user-select: none;
}
.command-palette-row {
.command-palette-v2-row {
display: flex;
width: 100%;
height: 36px;
@@ -113,16 +113,16 @@
scroll-margin: 6px 0;
}
.command-palette-row[data-active] {
.command-palette-v2-row[data-active] {
background: var(--v2-overlay-simple-overlay-hover);
}
.command-palette-row:focus-visible {
.command-palette-v2-row:focus-visible {
background: var(--v2-overlay-simple-overlay-hover);
outline: none;
}
.command-palette-row-main {
.command-palette-v2-row-main {
display: flex;
min-width: 0;
flex: 1;
@@ -130,19 +130,19 @@
gap: 8px;
}
.command-palette-row-icon {
.command-palette-v2-row-icon {
flex-shrink: 0;
color: var(--v2-icon-icon-muted);
}
.command-palette-row-text {
.command-palette-v2-row-text {
display: flex;
min-width: 0;
align-items: center;
gap: 6px;
}
.command-palette-title {
.command-palette-v2-title {
min-width: 0;
overflow: hidden;
color: var(--v2-text-text-base);
@@ -154,8 +154,8 @@
white-space: nowrap;
}
.command-palette-description,
.command-palette-meta {
.command-palette-v2-description,
.command-palette-v2-meta {
min-width: 0;
overflow: hidden;
color: var(--v2-text-text-muted);
@@ -167,11 +167,11 @@
white-space: nowrap;
}
.command-palette-meta {
.command-palette-v2-meta {
flex-shrink: 0;
}
.command-palette-file-path {
.command-palette-v2-file-path {
display: flex;
min-width: 0;
align-items: baseline;
@@ -180,7 +180,7 @@
letter-spacing: -0.04px;
}
.command-palette-file-dir {
.command-palette-v2-file-dir {
min-width: 0;
overflow: hidden;
color: var(--v2-text-text-muted);
@@ -189,14 +189,14 @@
white-space: nowrap;
}
.command-palette-file-name {
.command-palette-v2-file-name {
flex-shrink: 0;
color: var(--v2-text-text-base);
font-weight: 530;
white-space: nowrap;
}
.command-palette-state {
.command-palette-v2-state {
display: grid;
min-height: 120px;
place-items: center;
@@ -208,13 +208,13 @@
}
@media (max-width: 640px) {
.command-palette-row-text {
.command-palette-v2-row-text {
flex-direction: column;
align-items: flex-start;
gap: 1px;
}
.command-palette-description {
.command-palette-v2-description {
max-width: 100%;
}
}
@@ -6,12 +6,14 @@ import { Icon } from "@opencode-ai/ui/icon"
import { Keybind } from "@opencode-ai/ui/keybind"
import { TextInput } from "@opencode-ai/ui/text-input"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js"
import { formatKeybindParts } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { useTabs } from "@/shell/tabs/tabs"
import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
import { getRelativeTime } from "@/shell/time"
import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"
import { commandPaletteOptions, formatKeybindParts, useCommand } from "@/context/command"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/servers"
import { useTabs } from "@/context/tabs"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
import { getRelativeTime } from "@/utils/time"
import {
createCommandPaletteCommandEntry,
createCommandPaletteFileEntry,
@@ -19,8 +21,8 @@ import {
createServerSessionEntries,
uniqueCommandPaletteEntries,
type CommandPaletteEntry,
} from "./palette"
import "./dialog.css"
} from "./command-palette"
import "./dialog-command-palette-v2.css"
function groups(entries: CommandPaletteEntry[]) {
const map = new Map<string, CommandPaletteEntry[]>()
@@ -28,12 +30,12 @@ function groups(entries: CommandPaletteEntry[]) {
return Array.from(map.entries()).map(([category, entries]) => ({ category, entries }))
}
export function matchesCommandPaletteEntry(entry: CommandPaletteEntry, query: string) {
function matchesEntry(entry: CommandPaletteEntry, query: string) {
const value = query.toLowerCase()
return [entry.title, entry.description, entry.category].some((text) => text?.toLowerCase().includes(value))
}
export function DialogCommandPalette(props: { onOpenFile?: (path: string) => void }) {
export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => void }) {
const palette = createCommandPaletteModel(props)
const loadItems = async (text: string) => {
const q = text.trim()
@@ -42,7 +44,7 @@ export function DialogCommandPalette(props: { onOpenFile?: (path: string) => voi
const [files, nextSessions] = await Promise.all([palette.file.searchFiles(q), Promise.resolve(palette.sessions(q))])
const category = palette.language.t("palette.group.files")
return [
...palette.commandEntries().filter((entry) => matchesCommandPaletteEntry(entry, q)),
...palette.commandEntries().filter((entry) => matchesEntry(entry, q)),
...nextSessions,
...files.map((path) => createCommandPaletteFileEntry(path, category)),
]
@@ -59,7 +61,69 @@ export function DialogCommandPalette(props: { onOpenFile?: (path: string) => voi
)
}
export function CommandPaletteView(props: {
export function DialogHomeCommandPaletteV2(props: {
server: ServerConnection.Any
onSelectSession: (entry: CommandPaletteEntry) => void
}) {
const command = useCommand()
const dialog = useDialog()
const global = useGlobal()
const language = useLanguage()
const serverCtx = global.ensureServerCtx(props.server)
const state = { cleanup: undefined as (() => void) | void, committed: false }
const commandEntries = createMemo(() => {
const category = language.t("palette.group.commands")
return commandPaletteOptions(command.options).map((option) => createCommandPaletteCommandEntry(option, category))
})
const sessions = createServerSessionEntries({
server: ServerConnection.key(props.server),
opened: serverCtx.projects.list,
stored: () => serverCtx.sync.data.project,
load: (search, signal) => serverCtx.sdk.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
untitled: () => language.t("command.session.new"),
category: () => language.t("command.category.session"),
})
const highlight = (item: CommandPaletteEntry | undefined) => {
state.cleanup?.()
state.cleanup = undefined
if (item?.type !== "command") return
state.cleanup = item.option?.onHighlight?.()
}
const select = (item: CommandPaletteEntry | undefined) => {
if (!item) return
state.committed = true
state.cleanup = undefined
dialog.close()
if (item.type === "command") {
item.option?.onSelect?.("palette")
return
}
if (item.type === "session") props.onSelectSession(item)
}
const loadItems = async (text: string) => {
const query = text.trim()
if (!query) return commandEntries().slice(0, 5)
return [...commandEntries().filter((entry) => matchesEntry(entry, query)), ...(await sessions(query))]
}
onCleanup(() => {
if (state.committed) return
state.cleanup?.()
})
return (
<CommandPaletteView
placeholder={language.t("palette.search.placeholder.home")}
loadItems={loadItems}
highlight={highlight}
select={select}
close={() => dialog.close()}
/>
)
}
function CommandPaletteView(props: {
placeholder: string
loadItems: (text: string) => CommandPaletteEntry[] | Promise<CommandPaletteEntry[]>
highlight: (item: CommandPaletteEntry | undefined) => void
@@ -124,9 +188,9 @@ export function CommandPaletteView(props: {
}
return (
<Dialog class="command-palette" size="large">
<DialogBody class="command-palette-body">
<div class="command-palette-search">
<Dialog class="command-palette-v2" size="large">
<DialogBody class="command-palette-v2-body">
<div class="command-palette-v2-search">
<TextInput
value={query()}
autofocus
@@ -139,21 +203,21 @@ export function CommandPaletteView(props: {
onKeyDown={handleKeyDown}
/>
</div>
<ScrollView class="command-palette-scroll" viewportRef={(el) => (resultsRef = el)}>
<div class="command-palette-results" role="listbox">
<ScrollView class="command-palette-v2-scroll" viewportRef={(el) => (resultsRef = el)}>
<div class="command-palette-v2-results" role="listbox">
<Show
when={visibleEntries().length > 0}
fallback={
<div class="command-palette-state">
<div class="command-palette-v2-state">
{entries.loading ? language.t("common.loading") : language.t("palette.empty")}
</div>
}
>
<For each={groupedEntries()}>
{(group) => (
<div class="command-palette-group">
<div class="command-palette-v2-group">
<Show when={group.category}>
<div class="command-palette-group-title">{group.category}</div>
<div class="command-palette-v2-group-title">{group.category}</div>
</Show>
<For each={group.entries}>
{(item) => (
@@ -198,7 +262,7 @@ function PaletteRow(props: {
return (
<button
type="button"
class="command-palette-row group"
class="command-palette-v2-row group"
role="option"
aria-selected={props.active}
data-active={props.active ? "" : undefined}
@@ -212,21 +276,21 @@ function PaletteRow(props: {
>
<Switch
fallback={
<div class="command-palette-row-main">
<FileIcon node={{ path: props.item.path ?? "", type: "file" }} class="command-palette-row-icon size-4" />
<div class="command-palette-file-path">
<span class="command-palette-file-dir">{getDirectory(props.item.path ?? "")}</span>
<span class="command-palette-file-name">{getFilename(props.item.path ?? "")}</span>
<div class="command-palette-v2-row-main">
<FileIcon node={{ path: props.item.path ?? "", type: "file" }} class="command-palette-v2-row-icon size-4" />
<div class="command-palette-v2-file-path">
<span class="command-palette-v2-file-dir">{getDirectory(props.item.path ?? "")}</span>
<span class="command-palette-v2-file-name">{getFilename(props.item.path ?? "")}</span>
</div>
</div>
}
>
<Match when={props.item.type === "command"}>
<div class="command-palette-row-main">
<div class="command-palette-row-text">
<span class="command-palette-title">{props.item.title}</span>
<div class="command-palette-v2-row-main">
<div class="command-palette-v2-row-text">
<span class="command-palette-v2-title">{props.item.title}</span>
<Show when={props.item.description}>
<span class="command-palette-description">{props.item.description}</span>
<span class="command-palette-v2-description">{props.item.description}</span>
</Show>
</div>
</div>
@@ -235,7 +299,7 @@ function PaletteRow(props: {
</Show>
</Match>
<Match when={props.item.type === "session"}>
<div class="command-palette-row-main">
<div class="command-palette-v2-row-main">
<div class="relative shrink-0">
<Show when={props.sessionOpen}>
<span
@@ -255,19 +319,19 @@ function PaletteRow(props: {
)}
</Show>
</div>
<div class="command-palette-row-text">
<span class="command-palette-title" classList={{ "opacity-70": !!props.item.archived }}>
<div class="command-palette-v2-row-text">
<span class="command-palette-v2-title" classList={{ "opacity-70": !!props.item.archived }}>
{props.item.title}
</span>
<Show when={props.item.description}>
<span class="command-palette-description" classList={{ "opacity-70": !!props.item.archived }}>
<span class="command-palette-v2-description" classList={{ "opacity-70": !!props.item.archived }}>
{props.item.description}
</span>
</Show>
</div>
</div>
<Show when={props.item.updated}>
<span class="command-palette-meta">
<span class="command-palette-v2-meta">
{getRelativeTime(new Date(props.item.updated!).toISOString(), props.language.t)}
</span>
</Show>
@@ -2,9 +2,9 @@
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { mockProviderAuth } from "@/runtime/server/sync"
import { mockProviderAuth } from "@/context/server-sync"
import { onCleanup, onMount } from "solid-js"
import { DialogConnectProvider, useProviderConnectController } from "./dialog"
import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider"
function ConnectProviderDialogStory() {
const dialog = useDialog()
@@ -48,7 +48,7 @@ export default {
id: "app-dialog-connect-provider",
}
export const Picker = {
export const V2 = {
render: () => (
<QueryClientProvider client={new QueryClient()}>
<ConnectProviderDialogStory />
@@ -57,17 +57,17 @@ export const Picker = {
}
export const ApiKey = {
render: renderConnection("openrouter", [{ type: "key", label: "API key" }]),
render: renderConnection("openrouter", [{ type: "api", label: "API key" }]),
}
export const OpenCodeZen = {
render: renderConnection("opencode", [{ type: "key", label: "API key" }]),
render: renderConnection("opencode", [{ type: "api", label: "API key" }]),
}
export const LoginMethods = {
render: renderConnection("openai", [
{ type: "oauth", label: "ChatGPT Pro/Plus (browser)" },
{ type: "oauth", label: "ChatGPT Pro/Plus (headless)" },
{ type: "key", label: "API key" },
{ type: "api", label: "API key" },
]),
}
@@ -7,17 +7,17 @@ import { Spinner } from "@opencode-ai/ui/spinner"
import { TextField } from "@opencode-ai/ui/text-field"
import { DialogBody, DialogHeader, DialogTitle, Dialog } from "@opencode-ai/ui/dialog"
import { TextInput } from "@opencode-ai/ui/text-input"
import { showToast } from "@/shell/notifications/toast"
import { showToast } from "@/utils/toast"
import { type Component, createMemo, createUniqueId, For, Match, onMount, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import { useParams } from "@solidjs/router"
import { ExternalLink } from "@/runtime/platform/external-link"
import { useLanguage } from "@/runtime/i18n/language"
import { useProviders } from "@/providers/catalog/providers"
import { useIntegrations } from "@/providers/catalog/integrations"
import { CustomProviderForm } from "@/providers/credentials/dialog"
import { decode64 } from "@/runtime/persistence/base64"
import { createProviderConnectionController, type ProviderConnectMethod } from "./controller"
import { ExternalLink } from "@/components/external-link"
import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers"
import { useIntegrations } from "@/hooks/use-integrations"
import { CustomProviderForm } from "./dialog-custom-provider"
import { decode64 } from "@/utils/base64"
import { createProviderConnectionController, type ProviderConnectMethod } from "./provider-connection-controller"
const CUSTOM_ID = "_custom"
type IntegrationForm = NonNullable<ProviderConnectMethod["form"]>[number]
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { validateCustomProvider } from "./form"
import { validateCustomProvider } from "./dialog-custom-provider-form"
const t = (key: string) => key
@@ -1,17 +1,45 @@
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog, DialogBody, DialogHeader, DialogTitle } from "@opencode-ai/ui/dialog"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { useMutation } from "@tanstack/solid-query"
import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@/shell/notifications/toast"
import { showToast } from "@/utils/toast"
import { batch, For } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { ExternalLink } from "@/runtime/platform/external-link"
import { useData } from "@/runtime/server/current"
import { useLanguage } from "@/runtime/i18n/language"
import { type FormState, headerRow, modelRow, validateCustomProvider } from "./form"
import { ExternalLink } from "@/components/external-link"
import { useData } from "@/context/server"
import { useLanguage } from "@/context/language"
import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
type Props = {
onBack: () => void
}
export function DialogCustomProvider(props: Props) {
const language = useLanguage()
return (
<Dialog class="h-full">
<DialogHeader>
<DialogTitle>
<IconButton
tabIndex={-1}
icon={<Icon name="arrow-left" />}
variant="ghost"
onClick={props.onBack}
aria-label={language.t("common.goBack")}
/>
</DialogTitle>
</DialogHeader>
<DialogBody>
<CustomProviderForm />
</DialogBody>
</Dialog>
)
}
export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
const dialog = useDialog()
@@ -1,8 +1,8 @@
.project-settings-dialog [data-slot="dialog-container"] {
.project-settings-v2-dialog [data-slot="dialog-container"] {
background: var(--v2-background-bg-base);
}
.project-settings-dialog [data-slot="dialog-body"] {
.project-settings-v2-dialog [data-slot="dialog-body"] {
padding: 0;
overflow: hidden;
}
@@ -11,14 +11,14 @@
height: 100%;
}
.project-settings-nav {
.project-settings-v2-nav {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
}
.project-settings-panel {
.project-settings-v2-panel {
display: flex;
flex-direction: column;
height: 100%;
@@ -27,18 +27,18 @@
user-select: none;
}
.project-settings-panel :is(input, textarea, [contenteditable="true"]) {
.project-settings-v2-panel :is(input, textarea, [contenteditable="true"]) {
user-select: text;
}
.project-settings-form {
.project-settings-v2-form {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.project-settings-scroll {
.project-settings-v2-scroll {
display: flex;
flex: 1;
flex-direction: column;
@@ -7,18 +7,18 @@ import { Tabs } from "@opencode-ai/ui/tabs"
import { Textarea } from "@opencode-ai/ui/textarea"
import { TextInput } from "@opencode-ai/ui/text-input"
import { For, Show, createSignal, startTransition } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { getProjectAvatarVariant, type LocalProject } from "@/shell/state/layout"
import { ServerConnection } from "@/runtime/server/registry"
import { LocationProvider } from "@/workspaces/location"
import { displayName, getProjectAvatarSource } from "@/shell/layout/helpers"
import { createEditProjectModel } from "./project-model"
import { ProjectSettingsExtensions } from "./project-extensions"
import { SettingsServerDataScope } from "@/settings/server-scope"
import "@/settings/settings.css"
import "./project-dialog.css"
import { useLanguage } from "@/context/language"
import { getProjectAvatarVariant, type LocalProject } from "@/context/layout"
import { ServerConnection } from "@/context/servers"
import { LocationProvider } from "@/context/location"
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
import { createEditProjectModel } from "./edit-project"
import { ProjectSettingsExtensions } from "./project-settings-extensions"
import { SettingsServerDataScope } from "./settings-server-picker"
import "./settings-v2/settings-v2.css"
import "./dialog-edit-project-v2.css"
export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) {
export function DialogEditProjectV2(props: { project: LocalProject; server: ServerConnection.Any }) {
return (
<SettingsServerDataScope server={props.server}>
<LocationProvider directory={props.project.worktree}>
@@ -46,7 +46,7 @@ function ProjectSettingsDialog(props: { project: LocalProject; server: ServerCon
)
return (
<Dialog size="x-large" variant="settings" class="project-settings-dialog">
<Dialog size="x-large" variant="settings" class="project-settings-v2-dialog">
<Tabs
orientation="vertical"
variant="settings"
@@ -55,7 +55,7 @@ function ProjectSettingsDialog(props: { project: LocalProject; server: ServerCon
class="project-settings-v2"
>
<Tabs.List>
<div class="project-settings-nav">
<div class="project-settings-v2-nav">
<Tabs.Trigger value="general">
<ProjectAvatar
fallback={projectName()}
@@ -75,9 +75,9 @@ function ProjectSettingsDialog(props: { project: LocalProject; server: ServerCon
</div>
</Tabs.List>
<Tabs.Content value="general" class="project-settings-panel">
<form onSubmit={model.submit} class="project-settings-form">
<div class="project-settings-scroll">
<Tabs.Content value="general" class="project-settings-v2-panel">
<form onSubmit={model.submit} class="project-settings-v2-form">
<div class="project-settings-v2-scroll">
<div class="project-settings-page-header">
<h2>{language.t("dialog.project.edit.title")}</h2>
<span>{language.t("project.settings.general.description")}</span>
@@ -189,9 +189,9 @@ function ProjectSettingsDialog(props: { project: LocalProject; server: ServerCon
</form>
</Tabs.Content>
<Tabs.Content value="scripts" class="project-settings-panel">
<form onSubmit={model.submit} class="project-settings-form">
<div class="project-settings-scroll">
<Tabs.Content value="scripts" class="project-settings-v2-panel">
<form onSubmit={model.submit} class="project-settings-v2-form">
<div class="project-settings-v2-scroll">
<div class="project-settings-page-header">
<h2>{language.t("project.settings.scripts")}</h2>
<span>{language.t("project.settings.scripts.description")}</span>
@@ -213,7 +213,7 @@ function ProjectSettingsDialog(props: { project: LocalProject; server: ServerCon
</form>
</Tabs.Content>
<Tabs.Content value="extensions" class="project-settings-panel">
<Tabs.Content value="extensions" class="project-settings-v2-panel">
<ProjectSettingsExtensions />
</Tabs.Content>
</Tabs>

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