Compare commits

..

23 Commits

Author SHA1 Message Date
Aiden Cline 1b98af3f9e fix(ai): validate Anthropic tool ordering 2026-08-21 13:38:19 -05:00
Kit Langton 7b349654e3 refactor(core): simplify provider runtime code (#43952) 2026-08-21 18:37:20 +00:00
Kit Langton 3d2652d7b9 refactor(core): simplify vcs state helpers (#43953) 2026-08-21 18:35:51 +00:00
Kit Langton 1dea4b9391 refactor(config): simplify utility workflows (#43947) 2026-08-21 18:34:19 +00:00
Kit Langton 15864304a5 refactor(core): clarify guards and constants (#43946) 2026-08-21 18:32:27 +00:00
Kit Langton e312d261a8 refactor(session): simplify history projection (#43944) 2026-08-21 18:25:47 +00:00
Kit Langton 2a83911c7e refactor(plugin): simplify websearch effects (#43943) 2026-08-21 18:23:10 +00:00
Kit Langton 0eaa04718c refactor(config): clarify discovery workflows (#43942) 2026-08-21 18:22:46 +00:00
Kit Langton 2e5ec616d2 feat(tui): optimistic session creation on first prompt (#43687) 2026-08-21 14:21:31 -04:00
opencode-agent[bot] b2551b4e5d fix(tui): stop tab marquee after one cycle (#43936)
Co-authored-by: jlongster <17031+jlongster@users.noreply.github.com>
2026-08-21 14:10:54 -04:00
Aiden Cline e81450809d fix(ai): gemini legacy tool media (#43920) 2026-08-21 12:51:26 -05:00
Kit Langton 2524e6be8b refactor(core): simplify persistence boundaries (#43929) 2026-08-21 17:48:27 +00:00
Kit Langton b58f29a4ef refactor(core): simplify plugin and config boundaries (#43930) 2026-08-21 17:48:17 +00:00
Kit Langton 8fec7e0e91 refactor(session): simplify execution and state workflows (#43933) 2026-08-21 17:48:02 +00:00
Kit Langton 94f9d32040 fix(core): re-inject nested instructions after compaction (#43723) 2026-08-21 13:13:24 -04:00
opencode-agent[bot] ea3e0dde19 feat(desktop): restore CLI installer menu item (#43906)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-21 15:49:55 +00:00
opencode-agent[bot] b731b11184 fix(core): recover unknown finish responses (#43900)
Co-authored-by: thdxr <826656+thdxr@users.noreply.github.com>
2026-08-21 11:18:58 -04:00
Shoubhit Dash 8676dcf705 feat(ai): enable azure responses websocket (#43896) 2026-08-21 20:25:11 +05:30
Shoubhit Dash 2636797c65 feat(ai): enable xai responses websocket (#43889) 2026-08-21 19:58:01 +05:30
opencode-agent[bot] e673807e39 fix(app): hide built-in plugins (#43890)
Co-authored-by: Brendonovich <Brendonovich@users.noreply.github.com>
2026-08-21 22:18:48 +08:00
Shoubhit Dash 9a3a1732f1 refactor(ai): declare responses websocket capability (#43885) 2026-08-21 19:24:34 +05:30
opencode-agent[bot] e03a147b71 test(app): route e2e mocks through HttpApi (#43138)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-08-21 21:41:43 +08:00
opencode-agent[bot] e461fdc2d0 chore: update nix node_modules hashes 2026-08-21 13:40:43 +00:00
95 changed files with 2258 additions and 1135 deletions
@@ -0,0 +1,5 @@
---
"@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.
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-PuNZrtSgh5F3KpXSM+bd+rYQuyzwWd+wCOnMJSDS2Z0=",
"aarch64-linux": "sha256-RYy8ZRf59FE/3+gICjvsZv3ekQvn+DTZaT9jefbK+0g=",
"aarch64-darwin": "sha256-1AsDK8xNj3RlzX2efbuEDEwaOLAgjFYaEvk7EkQkh4w=",
"x86_64-darwin": "sha256-8ONeOu9UmM0GRxVeOO3Uhk1yAOuW6R8tqBYswOVEkME="
"x86_64-linux": "sha256-8pRvkbUX2aZhFTFtFuUM6mPqZZhfC4mFd1+BXVMzEJk=",
"aarch64-linux": "sha256-df25TWdjjLKeLZJEfrDpgVaV8ZAZhHWPoV2IPIQ4U2w=",
"aarch64-darwin": "sha256-VjbOx7Zi9eTiPxqpKN3+EQWweBfJHf7y36sGSN1peg0=",
"x86_64-darwin": "sha256-q7nW4AR2OnepnDcPDtYECgcsXI+JRHOCWhPsAX8t7q0="
}
}
+59 -13
View File
@@ -463,9 +463,9 @@ const supportsNativeSystemUpdates = (request: LLMRequest) => {
return match[3] !== undefined && match[3].length <= 2 && Number(match[3]) >= 8
}
const endsInServerToolUse = (message: LLMRequest["messages"][number]) => {
const endsInServerToolResult = (message: LLMRequest["messages"][number]) => {
const last = message.content.at(-1)
return message.role === "assistant" && last?.type === "tool-call" && last.providerExecuted === true
return message.role === "assistant" && last?.type === "tool-result" && last.providerExecuted === true
}
const canUseNativeSystemUpdate = (messages: LLMRequest["messages"], index: number) => {
@@ -474,22 +474,68 @@ const canUseNativeSystemUpdate = (messages: LLMRequest["messages"], index: numbe
return (
previous !== undefined &&
previous.role !== "system" &&
(previous.role === "user" || previous.role === "tool" || endsInServerToolUse(previous)) &&
(previous.role === "user" || previous.role === "tool" || endsInServerToolResult(previous)) &&
next?.role !== "system" &&
(next === undefined || next.role === "assistant")
)
}
const splitsLocalToolResults = (messages: LLMRequest["messages"], index: number) => {
const pending = new Set<string>()
for (const message of messages.slice(0, index)) {
for (const part of message.content) {
if (message.role === "assistant" && part.type === "tool-call" && part.providerExecuted !== true)
pending.add(part.id)
if (message.role === "tool" && part.type === "tool-result") pending.delete(part.id)
const RESULT_ORDER_ERROR =
"Anthropic Messages local tool calls must be followed immediately by exactly one matching result per call"
const localToolOrderError = (messages: LLMRequest["messages"]): string | undefined => {
let index = 0
while (index < messages.length) {
const message = messages[index]
if (!message) return undefined
if (message.role === "tool")
return "Anthropic Messages tool results must immediately follow their matching local tool calls"
if (message.role !== "assistant") {
index += 1
continue
}
const firstLocalCall = message.content.findIndex(
(part) => part.type === "tool-call" && part.providerExecuted !== true,
)
if (firstLocalCall === -1) {
index += 1
continue
}
if (message.content.slice(firstLocalCall + 1).some((part) => part.type !== "tool-call"))
return "Anthropic Messages local tool calls must be the final content blocks in an assistant message"
const calls = message.content
.filter((part): part is ToolCallPart => part.type === "tool-call" && part.providerExecuted !== true)
.map((part) => part.id)
if (new Set(calls).size !== calls.length)
return "Anthropic Messages assistant messages cannot contain duplicate local tool call IDs"
const wireCallIDs = message.content
.filter((part): part is ToolCallPart => part.type === "tool-call")
.map((part) => scrubToolCallID(part.id))
if (new Set(wireCallIDs).size !== wireCallIDs.length)
return "Anthropic Messages tool call IDs must remain unique after normalization"
// Consecutive canonical tool messages are batched below into one Anthropic user turn.
const resultStart = index + 1
const resultEndOffset = messages.slice(resultStart).findIndex((item) => item.role !== "tool")
const resultEnd = resultEndOffset === -1 ? messages.length : resultStart + resultEndOffset
const resultMessages = messages.slice(resultStart, resultEnd)
if (resultMessages.length === 0) return RESULT_ORDER_ERROR
const resultParts = resultMessages.flatMap((item) => item.content)
if (resultParts.some((part) => part.type !== "tool-result" || part.providerExecuted === true)) return RESULT_ORDER_ERROR
const results = resultParts.flatMap((part) => (part.type === "tool-result" ? [part.id] : []))
const resultIDs = new Set(results)
if (
resultIDs.size !== results.length ||
results.length !== calls.length ||
calls.some((id) => !resultIDs.has(id))
)
return RESULT_ORDER_ERROR
index = resultEnd
}
return pending.size > 0
return undefined
}
const lowerNativeSystemUpdate = Effect.fn("AnthropicMessages.lowerNativeSystemUpdate")(function* (
@@ -511,12 +557,12 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
request: LLMRequest,
breakpoints: Cache.Breakpoints,
) {
const toolOrderError = localToolOrderError(request.messages)
if (toolOrderError) return yield* invalid(toolOrderError)
const messages: AnthropicMessage[] = []
for (const [index, message] of request.messages.entries()) {
if (message.role === "system") {
if (splitsLocalToolResults(request.messages, index))
return yield* invalid("Anthropic Messages system updates cannot split a local tool call from its tool result")
if (supportsNativeSystemUpdates(request) && canUseNativeSystemUpdate(request.messages, index)) {
messages.push(yield* lowerNativeSystemUpdate(message, breakpoints))
continue
+15 -1
View File
@@ -37,6 +37,10 @@ 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
@@ -284,8 +288,16 @@ 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)
@@ -367,6 +379,7 @@ 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),
@@ -375,7 +388,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
name: part.name,
content: text.join("\n"),
},
parts: media.length > 0 ? media : undefined,
parts: legacyToolMedia || media.length === 0 ? undefined : media,
},
})
}
@@ -387,6 +400,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
else contents.push({ role: "user", parts })
}
flushMedia()
return contents
})
@@ -22,6 +22,8 @@ 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>>
@@ -147,18 +149,19 @@ 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
? 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
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
return {
http: {
request: ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }),
@@ -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 } from "./open-responses-channel.js"
import { OpenResponsesChannel, type Options } from "./open-responses-channel.js"
import { OpenAIResponsesChannel } from "./openai-responses-channel.js"
const ADAPTER = "openai-responses"
@@ -247,12 +247,16 @@ const endpoint = Endpoint.path<OpenAIResponsesBody>(PATH, { baseURL: DEFAULT_BAS
const auth = Auth.none
export const httpTransport = HttpTransport.sseJson.with<OpenAIResponsesBody>()
export const transport = OpenResponsesChannel.transport<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({
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({
+26
View File
@@ -1,3 +1,4 @@
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"
@@ -10,6 +11,7 @@ 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.
@@ -40,6 +42,30 @@ 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({
+7 -1
View File
@@ -28,13 +28,19 @@ 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.httpTransport,
transport: OpenAIResponses.channelTransport({
id: "openai-responses",
name: "xAI Responses",
rotateAfterMs: RESPONSES_WEBSOCKET_ROTATE_AFTER_MS,
}),
defaults: { providerOptions: { store: false } },
})
@@ -1,29 +0,0 @@
{
"version": 1,
"metadata": {
"name": "anthropic-messages/accepts-malformed-assistant-tool-order-with-default-patch",
"recordedAt": "2026-05-05T20:09:16.245Z",
"tags": ["prefix:anthropic-messages", "provider:anthropic", "protocol:anthropic-messages", "tool"]
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.anthropic.com/v1/messages",
"headers": {
"anthropic-version": "2023-06-01",
"content-type": "application/json"
},
"body": "{\"model\":\"claude-haiku-4-5-20251001\",\"messages\":[{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"I will check the weather.\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"call_1\",\"name\":\"get_weather\",\"input\":{\"city\":\"Paris\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_1\",\"content\":\"{\\\"temperature\\\":\\\"72F\\\"}\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use that result to answer briefly.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get weather\",\"input_schema\":{\"type\":\"object\",\"properties\":{}}}],\"stream\":true,\"max_tokens\":4096}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01SikJVFaMR1XLMtavUhvuog\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":638,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":1,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"The\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" weather in Paris is currently 72°F.\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":638,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":14} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n"
}
}
]
}
@@ -1,29 +0,0 @@
{
"version": 1,
"metadata": {
"name": "anthropic-messages/rejects-malformed-assistant-tool-order-without-patch",
"recordedAt": "2026-05-05T20:08:42.597Z",
"tags": ["prefix:anthropic-messages", "provider:anthropic", "protocol:anthropic-messages", "tool", "sad-path"]
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.anthropic.com/v1/messages",
"headers": {
"anthropic-version": "2023-06-01",
"content-type": "application/json"
},
"body": "{\"model\":\"claude-haiku-4-5-20251001\",\"messages\":[{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"call_1\",\"name\":\"get_weather\",\"input\":{\"city\":\"Paris\"}},{\"type\":\"text\",\"text\":\"I will check the weather.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_1\",\"content\":\"{\\\"temperature\\\":\\\"72F\\\"}\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use that result to answer briefly.\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get weather\",\"input_schema\":{\"type\":\"object\",\"properties\":{}}}],\"stream\":true,\"max_tokens\":4096}"
},
"response": {
"status": 400,
"headers": {
"content-type": "application/json"
},
"body": "{\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"messages.1: `tool_use` ids were found without `tool_result` blocks immediately after: call_1. Each `tool_use` block must have a corresponding `tool_result` block in the next message.\"},\"request_id\":\"req_011Cak2XdJgnzxKCY2BC2Beh\"}"
}
}
]
}
@@ -1,45 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, AIError, Message, ToolCallPart } from "../../src/index.js"
import { LLMClient } from "../../src/route.js"
import * as Anthropic from "../../src/providers/anthropic.js"
import { weatherToolName } from "../recorded-scenarios.js"
import { recordedTests } from "../recorded-test.js"
const model = Anthropic.configure({
apiKey: process.env.ANTHROPIC_API_KEY ?? "fixture",
}).model("claude-haiku-4-5-20251001")
const malformedToolOrderRequest = LLM.request({
id: "recorded_anthropic_malformed_tool_order",
model,
messages: [
Message.assistant([
ToolCallPart.make({ id: "call_1", name: weatherToolName, input: { city: "Paris" } }),
{ type: "text", text: "I will check the weather." },
]),
Message.tool({ id: "call_1", name: weatherToolName, result: { temperature: "72F" } }),
Message.user("Use that result to answer briefly."),
],
tools: [{ name: weatherToolName, description: "Get weather", inputSchema: { type: "object", properties: {} } }],
})
const recorded = recordedTests({
prefix: "anthropic-messages",
provider: "anthropic",
protocol: "anthropic-messages",
requires: ["ANTHROPIC_API_KEY"],
options: { redact: { allowRequestHeaders: ["anthropic-version"] } },
})
describe("Anthropic Messages sad-path recorded", () => {
recorded.effect.with("rejects malformed assistant tool order", { tags: ["tool", "sad-path"] }, () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(malformedToolOrderRequest).pipe(Effect.flip)
expect(error).toBeInstanceOf(AIError)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect(error.reason.message).toContain("`tool_use` ids were found without `tool_result` blocks")
}),
)
})
@@ -292,7 +292,143 @@ describe("Anthropic Messages route", () => {
}),
).pipe(Effect.flip)
expect(error.message).toContain("system updates cannot split a local tool call from its tool result")
expect(error.message).toContain("must be followed immediately by exactly one matching result per call")
}),
)
it.effect("rejects user and assistant messages between local tool calls and results", () =>
Effect.gen(function* () {
const errors = yield* Effect.forEach([Message.user("Too early."), Message.assistant("Too early.")], (middle) =>
compileRequest(
LLM.request({
model,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
middle,
Message.tool({ id: "call_1", name: "lookup", result: "Done." }),
],
}),
).pipe(Effect.flip),
)
expect(errors.every((error) => error.message.includes("must be followed immediately"))).toBe(true)
}),
)
it.effect("rejects content after a local tool call in the same assistant message", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([
ToolCallPart.make({ id: "call_1", name: "lookup", input: {} }),
{ type: "text", text: "Too late." },
]),
Message.tool({ id: "call_1", name: "lookup", result: "Done." }),
],
}),
).pipe(Effect.flip)
expect(error.message).toContain("must be the final content blocks")
}),
)
it.effect("rejects incomplete, duplicate, mismatched, and orphaned local tool results", () =>
Effect.gen(function* () {
const calls = Message.assistant([
ToolCallPart.make({ id: "call_1", name: "lookup", input: {} }),
ToolCallPart.make({ id: "call_2", name: "lookup", input: {} }),
])
const errors = yield* Effect.forEach(
[
[calls, Message.tool({ id: "call_1", name: "lookup", result: "Done." })],
[
calls,
Message.tool({ id: "call_1", name: "lookup", result: "Done." }),
Message.tool({ id: "call_1", name: "lookup", result: "Again." }),
],
[
calls,
Message.tool({ id: "call_1", name: "lookup", result: "Done." }),
Message.tool({ id: "call_3", name: "lookup", result: "Unknown." }),
],
[Message.tool({ id: "call_1", name: "lookup", result: "Orphaned." })],
],
(messages) => compileRequest(LLM.request({ model, messages })).pipe(Effect.flip),
)
expect(errors.every((error) => error.message.includes("result"))).toBe(true)
}),
)
it.effect("rejects duplicate and normalization-colliding tool call IDs", () =>
Effect.gen(function* () {
const errors = yield* Effect.forEach(
[
["call.1", "call.1"],
["call.1", "call:1"],
],
(ids) =>
compileRequest(
LLM.request({
model,
messages: [
Message.assistant(
ids.map((id) => ToolCallPart.make({ id, name: "lookup", input: {} })),
),
...ids.map((id) => Message.tool({ id, name: "lookup", result: "Done." })),
],
}),
).pipe(Effect.flip),
)
expect(errors[0]?.message).toContain("duplicate local tool call IDs")
expect(errors[1]?.message).toContain("unique after normalization")
}),
)
it.effect("places native system updates only after completed server tool use", () =>
Effect.gen(function* () {
const call = {
type: "tool-call" as const,
id: "srvtoolu_1",
name: "web_search",
input: { query: "effect" },
providerExecuted: true,
}
const unresolved = yield* compileRequest(
LLM.request({
model: opus48,
messages: [Message.assistant([call]), Message.system("Update.")],
cache: "none",
}),
)
const completed = yield* compileRequest(
LLM.request({
model: opus48,
messages: [
Message.assistant([
call,
{
type: "tool-result",
id: "srvtoolu_1",
name: "web_search",
result: { type: "json", value: [] },
providerExecuted: true,
},
]),
Message.system("Update."),
],
cache: "none",
}),
)
expect(unresolved.body.messages[1]).toEqual({
role: "user",
content: [{ type: "text", text: "<system-update>\nUpdate.\n</system-update>" }],
})
expect(completed.body.messages[1]).toEqual({ role: "system", content: [{ type: "text", text: "Update." }] })
}),
)
@@ -362,8 +498,8 @@ describe("Anthropic Messages route", () => {
ToolCallPart.make({ id: "call_paris", name: "weather", input: { city: "Paris" } }),
ToolCallPart.make({ id: "call_london", name: "weather", input: { city: "London" } }),
]),
Message.tool({ id: "call_paris", name: "weather", result: { temperature: 22 } }),
Message.tool({ id: "call_london", name: "weather", result: { temperature: 18 } }),
Message.tool({ id: "call_paris", name: "weather", result: { temperature: 22 } }),
],
cache: "none",
}),
@@ -382,8 +518,8 @@ describe("Anthropic Messages route", () => {
{
role: "user",
content: [
{ type: "tool_result", tool_use_id: "call_paris", content: '{"temperature":22}' },
{ type: "tool_result", tool_use_id: "call_london", content: '{"temperature":18}' },
{ type: "tool_result", tool_use_id: "call_paris", content: '{"temperature":22}' },
],
},
])
+159 -5
View File
@@ -328,14 +328,18 @@ 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=="')
}),
@@ -368,11 +372,161 @@ 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" } },
],
},
])
}),
)
@@ -691,6 +691,134 @@ 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)
@@ -5,9 +5,11 @@ 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()
@@ -21,6 +23,7 @@ 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),
@@ -31,12 +34,18 @@ 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" }),
request: () => ({
url: () => "http://127.0.0.1:4096/api/session/session/message",
method: () => "GET",
headers: () => ({}),
postDataBuffer: () => null,
}),
fulfill: () => {
events.push("fulfill")
return Promise.resolve()
},
} as unknown as Route)
await started.promise
expect(events).toEqual(["start", "before"])
const released = performance.now()
@@ -45,3 +54,42 @@ 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" } })
})
+249
View File
@@ -0,0 +1,249 @@
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)]])),
)
}
+319 -322
View File
@@ -1,5 +1,9 @@
import type { Page, Route } from "@playwright/test"
import type { Page } 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)
@@ -39,9 +43,8 @@ type MockStreamWindow = Window & {
}
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const cursors = new Map<string, string>()
const state = { cursors: new Map<string, string>(), nextCursor: 0 }
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 }) => {
@@ -128,316 +131,331 @@ 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()
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,
)
if (route.request().method() === "OPTIONS") {
return route.fulfill({ status: 204, headers: corsHeaders })
}
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: {
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))
}),
)
.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({
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"
}`,
})
},
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,
)
: 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)
)
})
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" }]],
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 })),
),
),
})
}
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": "*" } })
}
const revertStage = path.match(/^\/api\/session\/([^/]+)\/revert\/stage$/)?.[1]
if (revertStage && route.request().method() === "POST") {
const body = route.request().postDataJSON()
if (!body || typeof body !== "object" || !("messageID" in body) || typeof body.messageID !== "string") {
return json(route, { error: "Invalid revert request" }, undefined, 400)
}
config.onRevertStage?.({ sessionID: revertStage, messageID: body.messageID })
return json(route, { data: { messageID: body.messageID } })
}
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()
})
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()),
)
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,
)
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 },
}
})
},
}),
)
}
function location(config: MockServerConfig) {
@@ -595,24 +613,3 @@ 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"}`,
})
}
@@ -0,0 +1,16 @@
import { describe, expect, test } from "bun:test"
import type { PluginInfo } from "@opencode-ai/client"
import { pluginLabels } from "./plugin"
describe("pluginLabels", () => {
test("omits built-in plugins", () => {
const plugins: PluginInfo[] = [
{ id: "opencode.internal", source: { type: "builtin" }, status: "active", tui: false },
{ id: "package-plugin", source: { type: "package", package: "example" }, status: "active", tui: false },
{ id: "local-plugin", source: { type: "local", path: "/tmp/plugin.ts" }, status: "active", tui: false },
{ id: "sdk-plugin", source: { type: "sdk" }, status: "active", tui: false },
]
expect(pluginLabels(plugins)).toEqual(["package-plugin", "local-plugin", "sdk-plugin"])
})
})
@@ -6,3 +6,7 @@ export function pluginLabel(plugin: PluginInfo) {
if (plugin.source.type === "local") return plugin.source.path
return plugin.source.type
}
export function pluginLabels(plugins: readonly PluginInfo[]) {
return plugins.filter((plugin) => plugin.source.type !== "builtin").map(pluginLabel)
}
@@ -236,6 +236,7 @@ export const DESKTOP_NATIVE_ENGLISH = {
"desktop.menu.window": "Window",
"desktop.menu.help": "Help",
"desktop.menu.checkForUpdates": "Check for Updates...",
"desktop.menu.installCli": "Install CLI...",
"desktop.menu.settings": "Settings",
"desktop.menu.reloadWebview": "Reload Webview",
"desktop.menu.restart": "Restart",
@@ -283,6 +284,11 @@ export const DESKTOP_NATIVE_ENGLISH = {
"desktop.updater.dialog.restart": "Restart",
"desktop.updater.dialog.later": "Later",
"desktop.cli.installed.title": "CLI Installed",
"desktop.cli.installed.message": "CLI installed to {{path}}\n\nRestart your terminal to use the 'opencode2' command.",
"desktop.cli.failed.title": "Installation Failed",
"desktop.cli.failed.message": "Failed to install CLI: {{error}}",
"desktop.recovery.action.relaunch": "Relaunch",
"desktop.recovery.action.exportLogs": "Export Logs",
"desktop.recovery.action.keepWaiting": "Keep Waiting",
@@ -6,7 +6,7 @@ import { useLanguage } from "@/runtime/i18n/language"
import { useData } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { useMcpToggle } from "@/providers/connect/mcp"
import { pluginLabel } from "@/providers/catalog/plugin"
import { pluginLabels } from "@/providers/catalog/plugin"
import { ExternalLink } from "@/runtime/platform/external-link"
import { InlineServerSelect } from "@/settings/server-select"
import "@/settings/settings.css"
@@ -45,9 +45,7 @@ export const SettingsExtensions: Component = () => {
() => serverSdk.connection.status() === "connected",
() => serverSdk.api.plugin.list().then((result) => result.data),
)
const plugins = createMemo<PluginRowItem[]>(() =>
(pluginList.latest ?? []).map((item) => ({ name: pluginLabel(item) })),
)
const plugins = createMemo<PluginRowItem[]>(() => pluginLabels(pluginList.latest ?? []).map((name) => ({ name })))
createEffect(() => {
if (serverSdk.connection.status() !== "connected") return
@@ -7,7 +7,7 @@ import { useMcpToggle } from "@/providers/connect/mcp"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServerSDK } from "@/runtime/server/client"
import { useData } from "@/runtime/server/current"
import { pluginLabel } from "@/providers/catalog/plugin"
import { pluginLabels } from "@/providers/catalog/plugin"
import { ExternalLink } from "@/runtime/platform/external-link"
type SkillItem = {
@@ -102,10 +102,10 @@ export const ProjectSettingsExtensions: Component = () => {
() => (serverSDK.connection.status() === "connected" ? directorySDK().directory : undefined),
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
)
const globalPlugins = createMemo(() => (globalPluginList.latest ?? []).map(pluginLabel))
const globalPlugins = createMemo(() => pluginLabels(globalPluginList.latest ?? []))
const projectPlugins = createMemo(() => {
const shared = new Set(globalPlugins())
return (projectPluginList.latest ?? []).map(pluginLabel).filter((name) => !shared.has(name))
return pluginLabels(projectPluginList.latest ?? []).filter((name) => !shared.has(name))
})
const serverSkills = createMemo(() => data.location.skill.list() ?? [])
@@ -2,6 +2,13 @@ import { describe, expect, test } from "bun:test"
import { DESKTOP_MENU } from "./desktop-menu"
describe("desktop menu", () => {
test("installs the CLI from the macOS application menu", () => {
const appMenu = DESKTOP_MENU.find((menu) => menu.id === "app")
const item = appMenu?.items?.find((entry) => entry.type === "item" && entry.action === "app.installCli")
expect(item).toEqual({ type: "item", labelKey: "desktop.menu.installCli", action: "app.installCli" })
})
test("exports logs through the desktop command registry", () => {
const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter(
(item) => item.type === "item" && item.labelKey === "desktop.menu.exportLogs",
@@ -4,6 +4,7 @@ export type DesktopMenuPlatform = "macos" | "windows"
export type DesktopMenuAction =
| "app.checkForUpdates"
| "app.installCli"
| "app.relaunch"
| "edit.undo"
| "edit.redo"
@@ -84,6 +85,7 @@ export const DESKTOP_MENU: DesktopMenu[] = [
action: "app.checkForUpdates",
enabled: "updater",
},
{ type: "item", labelKey: "desktop.menu.installCli", action: "app.installCli" },
{ type: "item", labelKey: "desktop.menu.settings", command: "settings.open", accelerator: { macos: "Cmd+," } },
{ type: "item", labelKey: "desktop.menu.reloadWebview", action: "view.reload" },
{ type: "item", labelKey: "desktop.menu.restart", action: "app.relaunch" },
+2 -2
View File
@@ -6,7 +6,7 @@ import { useMcpToggle } from "@/providers/connect/mcp"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useData } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { pluginLabel } from "@/providers/catalog/plugin"
import { pluginLabels } from "@/providers/catalog/plugin"
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
const parts = value.split(file)
@@ -39,7 +39,7 @@ export function StatusPopoverBody(props: { shown: boolean }) {
() => (props.shown ? sdk().directory : undefined),
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
)
const plugins = createMemo(() => (pluginList.latest ?? []).map(pluginLabel))
const plugins = createMemo(() => pluginLabels(pluginList.latest ?? []))
const pluginCount = createMemo(() => plugins().length)
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
@@ -996,7 +996,12 @@ export type ProviderInfo = {
body?: { [x: string]: any }
}
export type ModelCapabilities = { tools: boolean; input: Array<string>; output: Array<string> }
export type ModelCapabilities = {
tools: boolean
input: Array<string>
output: Array<string>
responsesWebsockets?: boolean
}
export type ModelCost = {
tier?: { type: "context"; size: number }
+119 -17
View File
@@ -13,6 +13,7 @@ import type {
McpResource,
McpServer,
ModelInfo,
ModelRef,
PermissionSavedInfo,
PermissionRequest,
PermissionReplyInput,
@@ -34,6 +35,7 @@ import type {
WebSearchProvider,
} from "../promise"
import { Worktree } from "@opencode-ai/schema/worktree"
import { SessionID } from "@opencode-ai/schema/session-id"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { isPermissionNotFoundError, type SessionPromptInput } from "../promise"
import { createStore, produce, reconcile } from "solid-js/store"
@@ -226,6 +228,33 @@ export function createData(config: CreateDataInput) {
// rollback — not on POST success, which typically precedes the echo.
const outbox = new Set<string>()
// Session IDs of optimistic create admissions still awaiting acknowledgement
// (the session.created echo or the create response itself). A failed create
// only rolls back a session the server never acknowledged. Unlike
// `creating`, this clears on the echo rather than request settlement.
const sessionOutbox = new Set<string>()
// In-flight optimistic creates by session ID. prompt() gates its POST on
// this so a prompt sent to a still-creating session waits for the session
// to exist server-side instead of failing with "not found".
const creating = new Map<string, Promise<unknown>>()
// Per-session send chain: prompts must be admitted in submission order,
// and HTTP gives no ordering across concurrent POSTs. Each prompt waits
// for the previous prompt's POST (settled, so one failure does not block
// the next) before sending its own.
const sending = new Map<string, Promise<unknown>>()
// Register `promise` under `key` until it settles. A later registration
// replaces an earlier one; settlement only clears its own entry.
function track(map: Map<string, Promise<unknown>>, key: string, promise: Promise<unknown>) {
map.set(key, promise)
const settle = () => {
if (map.get(key) === promise) map.delete(key)
}
void promise.then(settle, settle)
}
// Upsert an admitted inbox item into pending, input, and (for user and
// synthetic items) the visible transcript. Used by the inbox.enqueued
// handler and by optimistic prompt admission; the upsert is what reconciles
@@ -385,6 +414,7 @@ export function createData(config: CreateDataInput) {
store.session.pending[sessionID]?.forEach((item) => outbox.delete(item.id))
messageIndex.delete(sessionID)
sync.invalidate(`session:${sessionID}`)
sync.invalidate(`session.family:${sessionID}`)
sync.invalidate(`session.pending:${sessionID}`)
sync.invalidate(`session.message:${sessionID}`)
sync.invalidate(`session.permission:${sessionID}`)
@@ -434,6 +464,7 @@ export function createData(config: CreateDataInput) {
void result.project.sync().catch((error) => console.error("Failed to preload projects", error))
return
case "session.created":
sessionOutbox.delete(event.data.sessionID)
result.session.invalidate(event.data.sessionID)
void result.session.sync(event.data.sessionID)
// Band-aid: a newly created session starts empty, so live events can be its source of truth.
@@ -1110,46 +1141,117 @@ export function createData(config: CreateDataInput) {
sync.invalidate(`session.pending:${sessionID}`)
},
},
// Optimistic session creation: admit a local record under a
// client-minted ID so a session view can mount immediately, then create
// the session on the server. The session.created echo re-syncs the
// record by ID, so the durable payload replaces the client's guess.
// Returns the ID synchronously along with the in-flight request:
// callers gate session-dependent sends on the request (prompt() gates
// itself on any in-flight create of its session automatically).
create(input: {
id?: string
title?: string
agent?: string
model?: ModelRef
location?: LocationRef
projectID?: string
}) {
const { projectID, ...payload } = input
const id = payload.id ?? SessionID.create()
const location = payload.location ?? defaultLocation()
const fresh = !store.session.info[id]
if (fresh) {
const now = Date.now()
sessionOutbox.add(id)
result.session.remember({
id,
projectID: projectID ?? store.location[locationKey(location)]?.info?.project.id ?? "",
agent: payload.agent,
model: payload.model,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: now, updated: now },
title: payload.title,
location,
})
// A mounted optimistic session must not fetch its empty collections
// before creation settles. The session.created echo re-syncs info.
sync.complete(`session.family:${id}`)
sync.complete(`session.pending:${id}`)
sync.complete(`session.message:${id}`)
}
// Wrapped so even a synchronous client failure reaches the rollback.
const request = Promise.resolve()
.then(() => api().session.create({ ...payload, id, location }))
.then((info) => {
sessionOutbox.delete(id)
result.session.remember(info)
return info
})
.catch((error) => {
// Roll back only a record this call admitted and neither the echo
// nor the response has acknowledged: anything else is server state.
if (fresh && sessionOutbox.delete(id)) removeSession(id)
throw error
})
if (fresh) track(creating, id, request)
return { id, request }
},
// Optimistic prompt admission: render the prompt immediately under a
// client-minted ID, send it, and let the durable inbox.enqueued echo
// upsert that same ID with the server's payload. Server admission is
// idempotent per ID, so retrying with the identical payload cannot
// double-admit.
prompt(input: SessionPromptInput) {
const id = input.id ?? SessionMessage.ID.create()
prompt(input: SessionPromptInput & { gate?: Promise<unknown> }) {
const { gate, ...request } = input
const id = request.id ?? SessionMessage.ID.create()
// A retry may reuse an ID that is already rendered — and possibly
// already durable. Admit optimistically only for new IDs so a failed
// retry cannot roll back acknowledged state.
const fresh =
!messageIndex.get(input.sessionID)?.has(id) &&
!store.session.pending[input.sessionID]?.some((item) => item.id === id)
!messageIndex.get(request.sessionID)?.has(id) &&
!store.session.pending[request.sessionID]?.some((item) => item.id === id)
if (fresh) {
outbox.add(id)
admitLocal({
id,
sessionID: input.sessionID,
sessionID: request.sessionID,
timeCreated: Date.now(),
type: "user",
delivery: input.delivery ?? "steer",
delivery: request.delivery ?? "steer",
// Files and skills stay off the optimistic row: their durable
// forms are server-loaded (content, mime, resolution), so they
// fill in when the echo upserts the row.
payload: {
text: input.text,
agents: input.agents?.map((agent) => ({ ...agent })),
metadata: input.metadata,
text: request.text,
agents: request.agents?.map((agent) => ({ ...agent })),
metadata: request.metadata,
},
})
}
// Wrapped so even a synchronous client failure reaches the rollback.
return Promise.resolve()
.then(() => api().session.prompt({ ...input, id }))
.catch((error) => {
// Roll back only rows this call admitted and the echo has not
// acknowledged: anything else is server state.
if (fresh && outbox.delete(id)) retractLocal(input.sessionID, id)
throw error
})
// The POST additionally waits for the caller's gate, for any
// in-flight optimistic create of this session, and for the previous
// prompt's POST: the row renders now, the send happens once the
// session exists server-side and earlier prompts are admitted.
const previous = sending.get(request.sessionID)
const send = Promise.resolve()
.then(() => Promise.all([gate, creating.get(request.sessionID), previous]))
.then(() => api().session.prompt({ ...request, id }))
track(
sending,
request.sessionID,
send.then(
() => undefined,
() => undefined,
),
)
return send.catch((error) => {
// Roll back only rows this call admitted and the echo has not
// acknowledged: anything else is server state.
if (fresh && outbox.delete(id)) retractLocal(request.sessionID, id)
throw error
})
},
sync(sessionID: string, options?: { children?: boolean }) {
return sync.run(options?.children ? `session.family:${sessionID}` : `session:${sessionID}`, async () => {
+2 -3
View File
@@ -127,7 +127,7 @@ function prepareOptions(model: Info, pkg: string) {
options.timeout !== undefined && options.timeout !== null && options.timeout !== false
? AbortSignal.timeout(options.timeout)
: undefined,
].filter((item): item is AbortSignal | AbortController => Boolean(item))
].filter((item): item is AbortSignal | AbortController => item !== undefined && item !== null)
const chunkAbortCtl = signals.find((item): item is AbortController => item instanceof AbortController)
const abortSignals = signals.map((item) => (item instanceof AbortController ? item.signal : item))
if (abortSignals.length === 1) opts.signal = abortSignals[0]
@@ -346,8 +346,7 @@ function gatewayProviderOptions(modelID: ID, settings: Readonly<Record<string, u
const prefix = separator > 0 ? modelID.slice(0, separator) : undefined
if (prefix)
return { ...(gateway === undefined ? {} : { gateway }), [prefix === "amazon" ? "bedrock" : prefix]: model }
if (typeof gateway === "object" && gateway !== null && !Array.isArray(gateway))
return { gateway: { ...gateway, ...model } }
if (gateway !== undefined) return { gateway: { ...gateway, ...model } }
return { gateway: model }
}
+30 -32
View File
@@ -227,7 +227,7 @@ export function configured(options?: Options) {
commit?: (seq: number) => Effect.Effect<void>,
) {
return Effect.gen(function* () {
const durable = definition?.durable
const durable = definition.durable
if (durable) {
const aggregateID = (event.data as Record<string, unknown>)[durable.aggregate]
if (typeof aggregateID !== "string") {
@@ -391,14 +391,14 @@ export function configured(options?: Options) {
commit?: PublishOptions["commit"],
) {
return Effect.gen(function* () {
if (!definition?.durable && commit)
if (!definition.durable && commit)
return yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: "Local commit hooks require a durable event",
}),
)
if (definition?.durable) {
if (definition.durable) {
const aggregateID = (event.data as Record<string, unknown>)[definition.durable.aggregate]
if (typeof aggregateID !== "string")
return yield* commitDurableEvent(definition, event as Event.Payload, undefined, commit).pipe(
@@ -610,37 +610,35 @@ export function configured(options?: Options) {
) {
return Effect.gen(function* () {
const definition = Durable.get(event.type)
if (!definition?.durable) {
yield* Effect.die(
if (!definition?.durable)
return yield* Effect.die(
new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` }),
)
} else {
yield* durableLocks.withLock(event.aggregateID)(
Effect.gen(function* () {
const payload = {
id: event.id,
created: event.created ?? 0,
type: definition.type,
data: Schema.decodeUnknownSync(definition.data)(event.data),
} as Event.Payload
const committed = yield* commitDurableEvent(definition, payload, {
seq: event.seq,
aggregateID: event.aggregateID,
ownerID: options?.ownerID,
strictOwner: options?.strictOwner,
})
if (committed && options?.publish) {
yield* notify(
{
...payload,
durable: envelope(committed.aggregateID, committed.seq, definition.durable.version),
},
true,
)
}
}),
)
}
yield* durableLocks.withLock(event.aggregateID)(
Effect.gen(function* () {
const payload = {
id: event.id,
created: event.created ?? 0,
type: definition.type,
data: Schema.decodeUnknownSync(definition.data)(event.data),
} as Event.Payload
const committed = yield* commitDurableEvent(definition, payload, {
seq: event.seq,
aggregateID: event.aggregateID,
ownerID: options?.ownerID,
strictOwner: options?.strictOwner,
})
if (committed && options?.publish) {
yield* notify(
{
...payload,
durable: envelope(committed.aggregateID, committed.seq, definition.durable.version),
},
true,
)
}
}),
)
})
}
+1 -1
View File
@@ -134,7 +134,7 @@ const layer = Layer.effect(
}
return result
},
finalize: Effect.fn("Catalog.finalize")(function* (catalog) {
finalize: Effect.fn("Catalog.finalize")(function* () {
yield* bus.publish(Catalog.Event.Updated, {})
}),
})
+3 -3
View File
@@ -112,15 +112,15 @@ export const create = (
files: collected,
...(result.ok ? {} : { error: true }),
}
const content: Array<Content> = [{ type: "text", text: value.output }]
content.push(
const content: Array<Content> = [
{ type: "text", text: value.output },
...value.files.map((file) => ({
type: "file" as const,
uri: `data:${file.mime};base64,${file.data}`,
mime: file.mime,
...(file.name === undefined ? {} : { name: file.name }),
})),
)
]
const metadata: Metadata = {
toolCalls: value.toolCalls,
...(value.error ? { error: true } : {}),
+38 -46
View File
@@ -17,6 +17,7 @@ import {
Event,
} from "@opencode-ai/schema/config"
import { Integration } from "@opencode-ai/schema/integration"
import { isRecord } from "@opencode-ai/ai/utils/record"
import { Credential } from "./credential.js"
import { Bus } from "./bus.js"
import { Watcher } from "./filesystem/watcher.js"
@@ -156,6 +157,37 @@ export const layer = (options?: Options) =>
return new Document({ type: "document", path: AbsolutePath.make(filepath), info })
})
const loadWellknownEntry = Effect.fnUntraced(function* (entry: WellKnown.Entry) {
const auth = entry.manifest.auth
if (!auth) return []
const credential = (yield* credentials.list(entry.integrationID)).findLast(
(credential) => credential.value.type === "key",
)
if (!credential || credential.value.type !== "key") return []
const variables = { [auth.env]: credential.value.key }
const configs = yield* wellknown
.resolve(entry, variables)
.pipe(
Effect.catch(() =>
Effect.logWarning("failed to load wellknown config", { source: entry.origin }).pipe(
Effect.as([] as const),
),
),
)
return yield* Effect.forEach(configs, (config) =>
ConfigVariable.substitute({
type: "virtual",
source: entry.origin,
dir: entry.origin,
text: JSON.stringify(config),
env: variables,
}).pipe(
Effect.flatMap((text) => parseInfo(text, entry.origin)),
Effect.map((info) => (info ? new Document({ type: "document", info }) : undefined)),
),
).pipe(Effect.map((documents) => documents.filter((document) => document !== undefined)))
})
const loadWellknown = Effect.fn("Config.loadWellknown")(function* () {
const entries = yield* wellknown
.entries()
@@ -164,38 +196,7 @@ export const layer = (options?: Options) =>
Effect.logWarning("failed to discover wellknown config", { error }).pipe(Effect.as([] as const)),
),
)
return yield* Effect.forEach(entries, (entry) =>
Effect.gen(function* () {
const auth = entry.manifest.auth
if (!auth) return []
const credential = (yield* credentials.list(entry.integrationID)).findLast(
(credential) => credential.value.type === "key",
)
if (!credential || credential.value.type !== "key") return []
const variables = { [auth.env]: credential.value.key }
const configs = yield* wellknown
.resolve(entry, variables)
.pipe(
Effect.catch(() =>
Effect.logWarning("failed to load wellknown config", { source: entry.origin }).pipe(
Effect.as([] as const),
),
),
)
return yield* Effect.forEach(configs, (config) =>
ConfigVariable.substitute({
type: "virtual",
source: entry.origin,
dir: entry.origin,
text: JSON.stringify(config),
env: variables,
}).pipe(
Effect.flatMap((text) => parseInfo(text, entry.origin)),
Effect.map((info) => (info ? new Document({ type: "document", info }) : undefined)),
),
).pipe(Effect.map((documents) => documents.filter((document) => document !== undefined)))
}),
).pipe(Effect.map((documents) => documents.flat()))
return yield* Effect.forEach(entries, loadWellknownEntry).pipe(Effect.map((documents) => documents.flat()))
})
const loadDirectory = Effect.fnUntraced(function* (directory: AbsolutePath) {
@@ -449,20 +450,11 @@ type Edit = { readonly path: (string | number)[]; readonly value: unknown }
function changes(before: unknown, after: unknown, path: (string | number)[] = []): Edit[] {
if (Object.is(before, after)) return []
if (
before !== null &&
after !== null &&
typeof before === "object" &&
typeof after === "object" &&
!Array.isArray(before) &&
!Array.isArray(after)
) {
const previous = before as Record<string, unknown>
const next = after as Record<string, unknown>
return [...new Set([...Object.keys(previous), ...Object.keys(next)])].flatMap((key) => {
if (!(key in next)) return [{ path: [...path, key], value: undefined }]
if (!(key in previous)) return [{ path: [...path, key], value: next[key] }]
return changes(previous[key], next[key], [...path, key])
if (isRecord(before) && isRecord(after)) {
return [...new Set([...Object.keys(before), ...Object.keys(after)])].flatMap((key) => {
if (!(key in after)) return [{ path: [...path, key], value: undefined }]
if (!(key in before)) return [{ path: [...path, key], value: after[key] }]
return changes(before[key], after[key], [...path, key])
})
}
return [{ path, value: after }]
+1 -1
View File
@@ -793,7 +793,7 @@ function isPlainRecord(value: unknown): value is Record<string, unknown> {
}
function own(value: Record<string, unknown>, key: string) {
return Object.prototype.hasOwnProperty.call(value, key)
return Object.hasOwn(value, key)
}
function setOwn(value: Record<string, unknown>, key: string, item: unknown) {
+13 -17
View File
@@ -52,22 +52,19 @@ export const Plugin = define({
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const loadEntry = Effect.fnUntraced(function* (entry: Entry) {
if (entry.type === "document") return [entry]
if (entry.type !== "directory") return []
const files = yield* discover(fs, entry.path)
return yield* Effect.forEach(files, (file) =>
fs.readFileStringSafe(file.filepath).pipe(
Effect.map((content) => (content ? decode(file, content) : undefined)),
Effect.catch(() => Effect.succeed(undefined)),
),
).pipe(Effect.map((documents) => documents.filter((document): document is Document => document !== undefined)))
})
const load = Effect.fn("ConfigAgentPlugin.load")(function* () {
return yield* Effect.forEach(yield* config.entries(), (entry) => {
if (entry.type === "document") return Effect.succeed([entry])
if (entry.type !== "directory") return Effect.succeed([])
return Effect.gen(function* () {
const files = yield* discover(fs, entry.path)
return yield* Effect.forEach(files, (file) =>
fs.readFileStringSafe(file.filepath).pipe(
Effect.map((content) => (content ? decode(file, content) : undefined)),
Effect.catch(() => Effect.succeed(undefined)),
),
).pipe(
Effect.map((documents) => documents.filter((document): document is Document => document !== undefined)),
)
})
}).pipe(Effect.map((documents) => documents.flat()))
return yield* Effect.forEach(yield* config.entries(), loadEntry).pipe(Effect.map((documents) => documents.flat()))
})
const loaded = { documents: [] as Document[] }
const reload = load().pipe(
@@ -160,8 +157,7 @@ function isPathAction(action: string): action is PathAction {
}
function expandHome(resource: string, home: string) {
if (resource === "~") return home
if (resource === "$HOME") return home
if (resource === "~" || resource === "$HOME") return home
const relative = resource.startsWith("~/")
? resource.slice(2)
: resource.startsWith("$HOME/") || resource.startsWith("$HOME\\")
+7 -9
View File
@@ -17,16 +17,14 @@ export const Plugin = define({
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const loadEntry = Effect.fnUntraced(function* (entry: Entry) {
if (entry.type === "document") return [{ commands: entry.info.commands }]
if (entry.type !== "directory") return []
const commands = yield* loadDirectory(fs, entry.path)
return [{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) }]
})
const load = Effect.fn("ConfigCommandPlugin.load")(function* () {
return yield* Effect.forEach(yield* config.entries(), (entry) => {
if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }])
if (entry.type !== "directory") return Effect.succeed([])
return loadDirectory(fs, entry.path).pipe(
Effect.map((commands) => [
{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) },
]),
)
}).pipe(Effect.map((documents) => documents.flat()))
return yield* Effect.forEach(yield* config.entries(), loadEntry).pipe(Effect.map((documents) => documents.flat()))
})
const loaded = { documents: [] as { commands: Info["commands"] }[] }
const reload = load().pipe(
+5 -5
View File
@@ -54,7 +54,7 @@ export const Plugin = define({
"ConfigSkillPlugin.watchDirectory",
)(function* (directory: string) {
const target = path.resolve(directory)
const resolved = yield* fs.realPath(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
const resolved = yield* fs.realPath(directory).pipe(Effect.orElseSucceed(() => undefined))
if (resolved) {
yield* watch(resolved, "directory")
if (resolved !== target) yield* watch(target, "file")
@@ -65,7 +65,7 @@ export const Plugin = define({
if (
yield* fs.realPath(directory).pipe(
Effect.as(true),
Effect.catch(() => Effect.succeed(false)),
Effect.orElseSucceed(() => false),
)
) {
if (missing) yield* FiberMap.remove(watches, `file:${path.resolve(missing)}`)
@@ -124,11 +124,11 @@ export const Plugin = define({
for (const directory of directories) {
const files = yield* fs
.scan("{*.md,**/SKILL.md}", { cwd: directory, absolute: true, include: "file", symlink: true, dot: true })
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
.pipe(Effect.orElseSucceed(() => [] as string[]))
for (const filepath of files.toSorted()) {
const resolved = yield* fs.realPath(filepath).pipe(Effect.catch(() => Effect.succeed(filepath)))
const resolved = yield* fs.realPath(filepath).pipe(Effect.orElseSucceed(() => filepath))
if (!roots.some((root) => FSUtil.contains(root, resolved))) yield* watch(path.dirname(resolved), "directory")
const content = yield* fs.readFileStringSafe(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
const content = yield* fs.readFileStringSafe(filepath).pipe(Effect.orElseSucceed(() => undefined))
if (!content) continue
const parsed = SkillFile.parse(directory, filepath, content)
if (parsed._tag === "Skipped") {
+2 -3
View File
@@ -37,11 +37,10 @@ const substituteFiles = Effect.fnUntraced(function* (input: SubstituteInput, tex
const fs = yield* FSUtil.Service
const configDir = input.type === "path" ? path.dirname(input.path) : input.dir
const configSource = input.type === "path" ? input.path : input.source
const matches = Array.from(text.matchAll(/\{file:[^}]+\}/g))
let out = ""
let cursor = 0
for (const match of matches) {
for (const match of text.matchAll(/\{file:[^}]+\}/g)) {
const token = match[0]
const index = match.index
out += text.slice(cursor, index)
@@ -54,7 +53,7 @@ const substituteFiles = Effect.fnUntraced(function* (input: SubstituteInput, tex
continue
}
const filePath = token.replace(/^\{file:/, "").replace(/\}$/, "")
const filePath = token.slice("{file:".length, -1)
const expandedPath = filePath.startsWith("~/") ? path.join(os.homedir(), filePath.slice(2)) : filePath
const resolvedPath = path.isAbsolute(expandedPath) ? expandedPath : path.resolve(configDir, expandedPath)
const fileContent = yield* fs.readFileString(resolvedPath).pipe(
+13 -14
View File
@@ -62,31 +62,30 @@ const layer = Layer.effect(
value: decode(row.value),
})
}
const storedRows = (rows: ReadonlyArray<typeof CredentialTable.$inferSelect>) =>
rows.flatMap((row) => {
const credential = stored(row)
return credential ? [credential] : []
})
return Service.of({
all: Effect.fn("Credential.all")(function* () {
return (yield* db
all: Effect.fn("Credential.all")(() =>
db
.select()
.from(CredentialTable)
.orderBy(asc(CredentialTable.time_created))
.all()
.pipe(Effect.orDie)).flatMap((row) => {
const credential = stored(row)
return credential ? [credential] : []
})
}),
list: Effect.fn("Credential.list")(function* (integrationID) {
return (yield* db
.pipe(Effect.orDie, Effect.map(storedRows)),
),
list: Effect.fn("Credential.list")((integrationID) =>
db
.select()
.from(CredentialTable)
.where(eq(CredentialTable.integration_id, integrationID))
.orderBy(asc(CredentialTable.time_created))
.all()
.pipe(Effect.orDie)).flatMap((row) => {
const credential = stored(row)
return credential ? [credential] : []
})
}),
.pipe(Effect.orDie, Effect.map(storedRows)),
),
get: Effect.fn("Credential.get")(function* (id) {
const row = yield* db.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get().pipe(Effect.orDie)
return row ? stored(row) : undefined
+2 -22
View File
@@ -1,5 +1,4 @@
import { Context, Effect, Exit, Fiber, Layer, Scope, Semaphore, Stream } from "effect"
import { identity } from "effect/Function"
import { Context, Effect, Exit, Fiber, Layer, Scope, Semaphore } from "effect"
import { Reactivity } from "effect/unstable/reactivity"
import { SqlClient, Statement } from "effect/unstable/sql"
import type { Connection } from "effect/unstable/sql/SqlConnection"
@@ -167,26 +166,7 @@ const make = (options: Config) =>
}),
})
const connection = identity<Connection>({
execute(query, params, transformRows) {
return transformRows ? Effect.map(run(query, params), transformRows) : run(query, params)
},
executeRaw(query, params) {
return run(query, params)
},
executeValues(query, params) {
return runValues(query, params)
},
executeValuesUnprepared(query, params) {
return runValues(query, params)
},
executeUnprepared(query, params, transformRows) {
return this.execute(query, params, transformRows)
},
executeStream() {
return Stream.die("executeStream not implemented")
},
})
const connection = Sqlite.makeConnection(run, runValues, {})
const semaphore = yield* Semaphore.make(1)
const acquirer = semaphore.withPermits(1)(Effect.succeed(connection))
+2 -2
View File
@@ -4,13 +4,13 @@ import { Effect, Layer } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "./bus.js"
const Types = new Set(["agent.updated", "catalog.updated", "command.updated", "config.updated"])
const EVENT_TYPES = new Set(["agent.updated", "catalog.updated", "command.updated", "config.updated"])
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const bus = yield* Bus.Service
const unsubscribe = yield* bus.listen((event) =>
Types.has(event.type) ? Effect.logInfo("event", { event }) : Effect.void,
EVENT_TYPES.has(event.type) ? Effect.logInfo("event", { event }) : Effect.void,
)
yield* Effect.addFinalizer(() => unsubscribe)
}),
+2 -2
View File
@@ -13,10 +13,10 @@ export const cleanup = Effect.fn("FileRetention.cleanup")(function* (
files,
(file) =>
Effect.gen(function* () {
const info = yield* fs.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
const info = yield* fs.stat(file).pipe(Effect.orElseSucceed(() => undefined))
const mtime = info && Option.getOrUndefined(info.mtime)
if (!mtime || mtime.getTime() >= cutoff) return
yield* fs.remove(file).pipe(Effect.catch(() => Effect.void))
yield* fs.remove(file).pipe(Effect.ignore)
}),
{ concurrency: 8, discard: true },
)
+10 -15
View File
@@ -109,16 +109,11 @@ export const layer = Layer.effect(
},
)
const find = Effect.fn("Form.find")(function* (id: ID) {
return yield* Cache.getSuccess(forms, id).pipe(
Effect.flatMap((entry) =>
Option.match(entry, {
onNone: () => Effect.fail(new NotFoundError({ id })),
onSome: Effect.succeed,
}),
),
)
})
const requireEntry = Effect.fn("Form.requireEntry")((id: ID) =>
Cache.getSuccess(forms, id).pipe(
Effect.flatMap((entry) => Effect.fromOption(entry, () => new NotFoundError({ id }))),
),
)
const create = Effect.fn("Form.create")((input: CreateInput) =>
Effect.uninterruptible(
@@ -151,7 +146,7 @@ export const layer = Layer.effect(
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const form = yield* create(input)
const entry = yield* find(form.id).pipe(Effect.orDie)
const entry = yield* requireEntry(form.id).pipe(Effect.orDie)
return yield* restore(Deferred.await(entry.deferred)).pipe(
Effect.onInterrupt(() => Effect.ignore(cancel(form.id))),
)
@@ -160,7 +155,7 @@ export const layer = Layer.effect(
)
const get = Effect.fn("Form.get")(function* (id: ID) {
return (yield* find(id)).form
return (yield* requireEntry(id)).form
})
const list = Effect.fn("Form.list")(function* (input?: ListInput) {
@@ -172,13 +167,13 @@ export const layer = Layer.effect(
})
const state = Effect.fn("Form.state")(function* (id: ID) {
return (yield* find(id)).state
return (yield* requireEntry(id)).state
})
const reply = Effect.fn("Form.reply")((input: ReplyInput) =>
Effect.uninterruptible(
Effect.gen(function* () {
const entry = yield* find(input.id)
const entry = yield* requireEntry(input.id)
if (entry.state.status !== "pending") return yield* new AlreadySettledError({ id: input.id })
const invalid = validateAnswer(entry.form.fields, input.answer)
if (invalid) return yield* new InvalidAnswerError({ id: input.id, message: invalid })
@@ -197,7 +192,7 @@ export const layer = Layer.effect(
const cancel = Effect.fn("Form.cancel")((id: ID) =>
Effect.uninterruptible(
Effect.gen(function* () {
const entry = yield* find(id)
const entry = yield* requireEntry(id)
if (entry.state.status !== "pending") return yield* new AlreadySettledError({ id })
const next: TerminalState = { status: "cancelled" }
yield* bus.publish(Form.Event.Cancelled, { id, sessionID: entry.form.sessionID })
+13 -49
View File
@@ -171,7 +171,7 @@ const layer = Layer.effect(
const discover = Effect.fn("Git.repo.discover")(function* (input: AbsolutePath) {
const dotgit = yield* fs.up({ targets: [".git"], start: input, mode: "first" }).pipe(
Effect.map((matches) => matches[0]),
Effect.catch(() => Effect.succeed(undefined)),
Effect.orElseSucceed(() => undefined),
)
if (!dotgit) return undefined
@@ -346,17 +346,9 @@ const layer = Layer.effect(
gitDirectory: AbsolutePath
seed?: Repository
}) {
yield* fs.ensureDir(input.gitDirectory).pipe(
Effect.mapError(
(cause) =>
new OperationError({
operation: "create",
directory: input.gitDirectory,
message: "Failed to create Git storage",
cause,
}),
),
)
const operationError = (message: string) => (cause: unknown) =>
new OperationError({ operation: "create", directory: input.gitDirectory, message, cause })
yield* fs.ensureDir(input.gitDirectory).pipe(Effect.mapError(operationError("Failed to create Git storage")))
const repository = new Repository({
worktree: input.worktree,
gitDirectory: input.gitDirectory,
@@ -371,48 +363,20 @@ const layer = Layer.effect(
yield* fs.writeFileString(config, `${current.endsWith("\n") ? "\n" : "\n\n"}${snapshotConfigInclude}`, {
flag: "a",
})
}).pipe(
Effect.mapError(
(cause) =>
new OperationError({
operation: "create",
directory: input.gitDirectory,
message: "Failed to configure Git storage",
cause,
}),
),
)
}).pipe(Effect.mapError(operationError("Failed to configure Git storage")))
if (!input.seed) return repository
yield* fs.ensureDir(path.join(input.gitDirectory, "objects", "info")).pipe(
Effect.mapError(
(cause) =>
new OperationError({
operation: "create",
directory: input.gitDirectory,
message: "Failed to configure shared Git objects",
cause,
}),
),
)
yield* fs
.ensureDir(path.join(input.gitDirectory, "objects", "info"))
.pipe(Effect.mapError(operationError("Failed to configure shared Git objects")))
yield* fs
.writeFileString(
path.join(input.gitDirectory, "objects", "info", "alternates"),
path.join(input.seed.commonDirectory, "objects") + "\n",
)
.pipe(
Effect.mapError(
(cause) =>
new OperationError({
operation: "create",
directory: input.gitDirectory,
message: "Failed to configure shared Git objects",
cause,
}),
),
)
.pipe(Effect.mapError(operationError("Failed to configure shared Git objects")))
yield* fs
.copyFile(path.join(input.seed.gitDirectory, "index"), path.join(input.gitDirectory, "index"))
.pipe(Effect.catch(() => Effect.void))
.pipe(Effect.ignore)
return repository
})
@@ -439,7 +403,7 @@ const layer = Layer.effect(
? new Set(
(yield* repositoryOperation("refresh", input.ignores, ["check-ignore", "--no-index", "--stdin", "-z"], {
stdin: candidates.join("\0") + "\0",
}).pipe(Effect.catch(() => Effect.succeed({ text: "", stderr: "" })))).text
}).pipe(Effect.orElseSucceed(() => ({ text: "", stderr: "" })))).text
.split("\0")
.filter(Boolean),
)
@@ -454,7 +418,7 @@ const layer = Layer.effect(
Effect.map((info) =>
info.type === "File" && Number(info.size) > maximum ? RelativePath.make(item) : undefined,
),
Effect.catch(() => Effect.succeed(undefined)),
Effect.orElseSucceed(() => undefined),
),
{ concurrency: 8 },
)).filter((item): item is RelativePath => item !== undefined)
@@ -752,7 +716,7 @@ interface Result {
function run(cwd: string, proc: AppProcess.Interface) {
return (args: string[]) =>
execute(cwd, proc)(args).pipe(Effect.catch(() => Effect.succeed({ exitCode: 1, text: "", stderr: "" })))
execute(cwd, proc)(args).pipe(Effect.orElseSucceed(() => ({ exitCode: 1, text: "", stderr: "" })))
}
function execute(cwd: string, proc: AppProcess.Interface) {
+1 -2
View File
@@ -42,8 +42,7 @@ export { createID as create }
export function timestamp(id: string): number {
const prefix = id.split("_")[0]
const hex = id.slice(prefix.length + 1, prefix.length + 13)
const encoded = BigInt("0x" + hex)
return Number(encoded / BigInt(0x1000))
return Number(BigInt(`0x${hex}`) / 0x1000n)
}
export * as Identifier from "./id.js"
+8 -7
View File
@@ -34,6 +34,7 @@ import { MCPStdio } from "./stdio.js"
const DEFAULT_STARTUP_TIMEOUT = 30_000
const DEFAULT_CATALOG_TIMEOUT = 30_000
const DEFAULT_EXECUTION_TIMEOUT = 12 * 60 * 60 * 1_000 // 12 hours
const toError = (error: unknown) => (error instanceof Error ? error : new Error(String(error)))
// Some servers advertise tool outputSchemas the SDK's strict validator can't resolve; this drops
// only that field so a single bad schema doesn't blank out the whole tool list.
@@ -261,7 +262,7 @@ export const connect = Effect.fnUntraced(function* (
},
(result) => result.tools,
),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
catch: toError,
}).pipe(
Effect.tapError((error) => Effect.logWarning("failed to list MCP tools", { server, error: error.message })),
)
@@ -286,7 +287,7 @@ export const connect = Effect.fnUntraced(function* (
},
(result) => result.prompts,
),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
catch: toError,
}).pipe(
Effect.tapError((error) =>
Effect.logWarning("failed to list MCP prompts", { server, error: error.message }),
@@ -312,7 +313,7 @@ export const connect = Effect.fnUntraced(function* (
client.listResources(cursor === undefined ? undefined : { cursor }, { timeout: catalogTimeout }),
(result) => result.resources,
),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
catch: toError,
}).pipe(
Effect.tapError((error) =>
Effect.logWarning("failed to list MCP resources", { server, error: error.message }),
@@ -337,7 +338,7 @@ export const connect = Effect.fnUntraced(function* (
}),
(result) => result.resourceTemplates,
),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
catch: toError,
}).pipe(
Effect.tapError((error) =>
Effect.logWarning("failed to list MCP resource templates", { server, error: error.message }),
@@ -355,7 +356,7 @@ export const connect = Effect.fnUntraced(function* (
if (!client.getServerCapabilities()?.resources) return undefined
const result = yield* Effect.tryPromise({
try: (signal) => client.readResource({ uri: input.uri }, { signal, timeout: executionTimeout }),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
catch: toError,
}).pipe(
Effect.tapError((error) =>
Effect.logWarning("failed to read MCP resource", { server, uri: input.uri, error: error.message }),
@@ -378,7 +379,7 @@ export const connect = Effect.fnUntraced(function* (
GetPromptResultSchema,
{ signal, timeout: executionTimeout },
),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
catch: toError,
}).pipe(
Effect.map((result) => ({
messages: result.messages.map((message) => ({ role: message.role, content: message.content })),
@@ -393,7 +394,7 @@ export const connect = Effect.fnUntraced(function* (
// Keep progress tokens available while enforcing a hard wall-clock execution timeout.
{ signal, timeout: executionTimeout, onprogress: () => {} },
),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
catch: toError,
}).pipe(
Effect.map((result) => ({
isError: result.isError === true,
+3 -2
View File
@@ -2,6 +2,7 @@ export * as Plugin from "./plugin.js"
export { Event, ID, Info, Source } from "@opencode-ai/schema/plugin"
import { Plugin } from "@opencode-ai/schema/plugin"
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { App } from "./app.js"
import { Cause, Context, Effect, Exit, Layer, Logger, References, Scope, Semaphore } from "effect"
@@ -31,7 +32,7 @@ export interface Interface {
readonly list: () => Effect.Effect<Plugin.Info[]>
}
export type Versioned = import("@opencode-ai/plugin/effect/plugin").Plugin & {
export type Versioned = PluginDefinition & {
readonly version: string
readonly source?: Plugin.Source
}
@@ -47,7 +48,7 @@ const layer = Layer.effect(
const active = new Map<Plugin.ID, { readonly plugin: Versioned; readonly scope: Scope.Closeable }>()
const lock = Semaphore.makeUnsafe(1)
let inventory: Plugin.Info[] = []
let host: Parameters<import("@opencode-ai/plugin/effect/plugin").Plugin["effect"]>[0]
let host: Parameters<PluginDefinition["effect"]>[0]
const load = Effect.fnUntraced(function* (plugin: Versioned) {
const child = yield* Scope.fork(scope)
const inherit = yield* State.inherit()
+2 -4
View File
@@ -27,12 +27,10 @@ import { Tool } from "../tool.js"
import { Workspace } from "../workspace.js"
import { WebSearch } from "../websearch.js"
import { PluginHooks } from "./hooks.js"
import type { Interface } from "../plugin.js"
const mutable = <T>(value: T) => value as DeepMutable<T>
export const make = Effect.fn("PluginHost.make")(function* (
plugin: import("../plugin.js").Interface,
pluginID: string = "test",
) {
export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, pluginID: string = "test") {
const app = yield* App.Metadata
const agents = yield* Agent.Service
const aisdk = yield* AISDK.Service
@@ -86,10 +86,6 @@ export const AmazonBedrockPlugin = define({
process.env.AWS_BEARER_TOKEN_BEDROCK ??
(typeof options.bearerToken === "string" ? options.bearerToken : undefined)
if (bearerToken && !process.env.AWS_BEARER_TOKEN_BEDROCK) process.env.AWS_BEARER_TOKEN_BEDROCK = bearerToken
const containerCreds = Boolean(
process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI || process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI,
)
options.region = region
if (typeof options.endpoint === "string") options.baseURL = options.endpoint
if (!bearerToken && options.credentialProvider === undefined) {
+26 -15
View File
@@ -1,6 +1,7 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Form } from "@opencode-ai/schema/form"
import { Model } from "../../model.js"
import { Provider } from "../../provider.js"
import { iife } from "../../util/iife.js"
import { configuredSettings } from "./configured.js"
@@ -44,23 +45,24 @@ export const AzurePlugin = define({
if (item.provider.id !== Provider.ID.azure && Provider.packageName(item.provider.package) !== "@ai-sdk/azure")
continue
const resourceName = resolveResourceName(item.provider.settings)
if (!resourceName) continue
evt.provider.update(item.provider.id, (provider) => {
provider.settings = {
...provider.settings,
resourceName,
...(typeof provider.settings?.baseURL === "string"
? { baseURL: expandResourceName(provider.settings.baseURL, resourceName) }
: {}),
}
})
if (resourceName)
evt.provider.update(item.provider.id, (provider) => {
provider.settings = {
...provider.settings,
resourceName,
...(typeof provider.settings?.baseURL === "string"
? { baseURL: expandResourceName(provider.settings.baseURL, resourceName) }
: {}),
}
})
for (const model of item.models.values()) {
evt.model.update(item.provider.id, model.id, (draft) => {
if (typeof draft.settings?.baseURL !== "string") return
draft.settings.baseURL = expandResourceName(
draft.settings.baseURL,
resolveResourceName(draft.settings, resourceName) ?? resourceName,
)
if (resourceName && typeof draft.settings?.baseURL === "string")
draft.settings.baseURL = expandResourceName(
draft.settings.baseURL,
resolveResourceName(draft.settings, resourceName) ?? resourceName,
)
if (responsesWebSocketCapable(item.provider, draft)) draft.capabilities.responsesWebsockets = true
})
}
}
@@ -107,3 +109,12 @@ function expandResourceName(baseURL: string, resourceName: string) {
.replaceAll("${AZURE_RESOURCE_NAME}", resourceName)
.replaceAll("${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}", resourceName)
}
function responsesWebSocketCapable(provider: Provider.Info, model: Model.Info) {
if (Provider.packageName(model.package ?? provider.package) !== "@ai-sdk/azure") return false
const settings = Provider.mergeOverlay(provider.settings, model.settings)
if (settings?.useCompletionUrls === true || settings?.useDeploymentBasedUrls === true) return false
if (settings?.apiVersion !== undefined && settings.apiVersion !== "v1") return false
if (typeof settings?.baseURL !== "string") return true
return /^https:\/\/[^/]+\.openai\.azure\.com(?:\/|$)/i.test(settings.baseURL)
}
+6 -1
View File
@@ -190,9 +190,14 @@ export const OpenAIPlugin = define({
})
yield* load()
yield* ctx.catalog.transform((evt) => {
if (!chatgpt) return
const item = evt.provider.get(Provider.ID.openai)
if (!item) return
for (const model of item.models.values()) {
evt.model.update(item.provider.id, model.id, (draft) => {
draft.capabilities.responsesWebsockets = true
})
}
if (!chatgpt) return
item.provider.settings = Provider.mergeOverlay(item.provider.settings, { baseURL: codexBaseURL })
const account = chatgpt.metadata?.accountID
item.provider.headers = Provider.mergeHeaders(item.provider.headers, {
+11
View File
@@ -4,6 +4,7 @@ import { Clock, Effect, Option, Schema } from "effect"
import { App } from "../../app.js"
import { Credential } from "../../credential.js"
import { Integration } from "../../integration.js"
import { Provider } from "../../provider.js"
const clientID = "b1a00492-073a-47ea-816f-4c329264a828"
const issuer = "https://auth.x.ai/oauth2"
@@ -12,6 +13,7 @@ const scope = "openid profile email offline_access grok-cli:access api:access"
const pollingSafetyMargin = 3000
const browserMethodID = Integration.MethodID.make("browser")
const deviceMethodID = Integration.MethodID.make("device")
const providerID = Provider.ID.make("xai")
const Token = Schema.Struct({
access_token: Schema.String,
@@ -93,6 +95,15 @@ export const XAIPlugin = define({
draft.method.update(device(ctx.app))
draft.method.update({ integrationID: "xai", method: { type: "key", label: "Manually enter API Key" } })
})
yield* ctx.catalog.transform((catalog) => {
const provider = catalog.provider.get(providerID)
if (!provider) return
for (const model of provider.models.values()) {
catalog.model.update(providerID, model.id, (draft) => {
draft.capabilities.responsesWebsockets = true
})
}
})
}),
})
+1 -2
View File
@@ -54,11 +54,10 @@ export interface Cell {
export const makeCell = (): Cell => ({})
const unavailable = <A, E, R>() => Effect.die(new Error("Plugin runtime is unavailable")) as Effect.Effect<A, E, R>
const require = <A, E, R>(cell: Cell, f: (runtime: Interface) => Effect.Effect<A, E, R>) =>
Effect.suspend(() => {
const runtime = cell.runtime
if (runtime === undefined) return unavailable<A, E, R>()
if (runtime === undefined) return Effect.die(new Error("Plugin runtime is unavailable"))
return f(runtime)
})
+3 -3
View File
@@ -45,9 +45,9 @@ export const call = <F extends Schema.Struct.Fields, R extends Schema.Struct.Fie
params: Schema.Struct({ name: Schema.String, arguments: schema.input }),
}),
)({
jsonrpc: "2.0" as const,
id: 1 as const,
method: "tools/call" as const,
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: { name: tool, arguments: value },
}),
)
+9 -9
View File
@@ -63,15 +63,15 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
max_results: 8,
}),
)
const response = yield* Effect.gen(function* () {
const httpResponse = yield* HttpClient.filterStatusOk(http).execute(request)
return yield* HttpClientResponse.schemaBodyJson(SearchResponse)(httpResponse)
}).pipe(
Effect.timeoutOrElse({
duration: Duration.seconds(25),
orElse: () => Effect.fail(new Error("Tavily web search request timed out")),
}),
)
const response = yield* HttpClient.filterStatusOk(http)
.execute(request)
.pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(SearchResponse)),
Effect.timeoutOrElse({
duration: Duration.seconds(25),
orElse: () => Effect.fail(new Error("Tavily web search request timed out")),
}),
)
return response.results.map((item) => ({
url: item.url,
title: item.title,
+2 -12
View File
@@ -3,6 +3,7 @@ export * as Provider from "./provider.js"
import { Effect, Schema } from "effect"
import { Provider } from "@opencode-ai/schema/provider"
import type { ProviderPackageDefinition } from "@opencode-ai/ai"
import { isRecord } from "@opencode-ai/ai/utils/record"
import { Npm } from "@opencode-ai/util/npm"
import type { DeepMutable } from "./schema.js"
import { importModule, resolveModule } from "@opencode-ai/util/runtime-import"
@@ -108,18 +109,7 @@ export function mergeOverlay(
const left = base[key]
const right = overlay[key]
if (right === undefined) return [key, left]
if (
typeof left === "object" &&
left !== null &&
!Array.isArray(left) &&
typeof right === "object" &&
right !== null &&
!Array.isArray(right)
)
return [
key,
mergeOverlay(left as Readonly<Record<string, unknown>>, right as Readonly<Record<string, unknown>>) ?? {},
]
if (isRecord(left) && isRecord(right)) return [key, mergeOverlay(left, right) ?? {}]
return [key, right]
}),
),
+14 -21
View File
@@ -88,6 +88,12 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Ri
const failure = (message: string, cause?: unknown) => new Error({ message, cause })
const normalizePath = (value: string) =>
value
.replace(/^(?:\.[\\/])+/u, "")
.replace(/^[\\/]+/u, "")
.replaceAll("\\", "/")
const isInvalidPattern = (stderr: string) =>
stderr.includes("regex parse error") || stderr.includes("error parsing regex")
@@ -169,13 +175,7 @@ const layer = Layer.effect(
"--glob=!**/.git/**",
".",
],
parse: (line) =>
Effect.succeed(
line
.replace(/^(?:\.[\\/])+/u, "")
.replace(/^[\\/]+/u, "")
.replaceAll("\\", "/"),
),
parse: (line) => Effect.succeed(normalizePath(line)),
}).pipe(
Effect.map((result) =>
result.items.map((relative) =>
@@ -203,10 +203,7 @@ const layer = Layer.effect(
".",
],
parse: (line) => {
const relative = line
.replace(/^(?:\.[\\/])+/u, "")
.replace(/^[\\/]+/u, "")
.replaceAll("\\", "/")
const relative = normalizePath(line)
return Effect.succeed(
Entry.make({
path: RelativePath.make(relative),
@@ -242,7 +239,7 @@ const layer = Layer.effect(
return Schema.decodeUnknownEffect(RawMatch)(json).pipe(
Effect.map((match) => ({
...match.data,
path: { text: match.data.path.text.replace(/^\.[\\/]/, "") },
path: { text: normalizePath(match.data.path.text) },
submatches: match.data.submatches.slice(0, MAX_SUBMATCHES),
})),
Effect.mapError((cause) => failure("Invalid ripgrep match output", cause)),
@@ -251,14 +248,10 @@ const layer = Layer.effect(
),
}).pipe(
Effect.map((result) =>
result.items.map((match) => {
const relative = match.path.text
.replace(/^(?:\.[\\/])+/u, "")
.replace(/^[\\/]+/u, "")
.replaceAll("\\", "/")
return Match.make({
result.items.map((match) =>
Match.make({
entry: Entry.make({
path: RelativePath.make(relative),
path: RelativePath.make(match.path.text),
type: "file",
}),
line: match.line_number,
@@ -269,8 +262,8 @@ const layer = Layer.effect(
start: submatch.start,
end: submatch.end,
})),
})
}),
}),
),
),
),
})
+13 -21
View File
@@ -29,26 +29,6 @@ export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService
.pipe(Effect.orDie)
})
const messageRows = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
compaction: { readonly seq: number } | undefined,
) {
const rows = yield* db
.select()
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, sessionID),
compaction ? gte(SessionMessageTable.seq, compaction.seq) : undefined,
),
)
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
return rows
})
const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =>
decode({ ...row.data, id: row.id, type: row.type }).pipe(
Effect.mapError(
@@ -61,7 +41,19 @@ const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =>
)
const messageEntries = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
const rows = yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID))
const compaction = yield* latestCompaction(db, sessionID)
const rows = yield* db
.select()
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, sessionID),
compaction ? gte(SessionMessageTable.seq, compaction.seq) : undefined,
),
)
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
return yield* Effect.forEach(rows, (row) =>
decodeMessageRow(row).pipe(Effect.map((message) => ({ seq: row.seq, message }))),
)
+5 -5
View File
@@ -157,14 +157,14 @@ export const admit = Effect.fn("SessionInbox.admit")(function* (
item: request.item,
})
.pipe(
Effect.flatMap((event) => {
const base = {
Effect.map((event) =>
Info.make({
id: request.id,
sessionID: request.sessionID,
timeCreated: DateTime.makeUnsafe(event.created),
}
return Effect.succeed(Info.make({ ...base, ...request.item }))
}),
...request.item,
}),
),
Effect.catchDefect((defect) =>
find(db, request.id).pipe(
Effect.flatMap((stored) =>
+42 -27
View File
@@ -37,15 +37,17 @@ const layer = Layer.effect(
// root so opening a subdirectory still describes paths from the project root.
const root = yield* fs.resolve(location.project.directory)
// Same-step parallel reads settle concurrently, so an in-memory claim guards each
// Session/path pair before any filesystem work. The durable history check below covers
// paths injected in earlier steps after this Location layer was reopened.
const injected = yield* Ref.make<Map<SessionSchema.ID, Set<string>>>(new Map())
// Session/path pair while a load is in flight. The claim is released once the load
// settles: the synthetic message metadata scanned below is the only lasting ledger,
// so paths whose synthetics drop out of model-visible history (compaction, revert)
// are re-discovered and re-injected instead of staying silently lost.
const inFlight = yield* Ref.make<Map<SessionSchema.ID, Set<string>>>(new Map())
const load = Effect.fn("SessionInstructions.load")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly paths: ReadonlyArray<string>
}) {
const claimed = yield* Ref.modify(injected, (map) => {
const claimed = yield* Ref.modify(inFlight, (map) => {
const existing = map.get(input.sessionID) ?? new Set<string>()
const newlyClaimed = input.paths.filter((path) => !existing.has(path))
if (newlyClaimed.length === 0) return [newlyClaimed, map]
@@ -54,30 +56,43 @@ const layer = Layer.effect(
return [newlyClaimed, next]
})
if (claimed.length === 0) return
const alreadyInjected = yield* previouslyInjected(store, input.sessionID)
const toInject = claimed.filter((path) => !alreadyInjected.has(path))
if (toInject.length === 0) return
const files = yield* Effect.forEach(
toInject,
(path) =>
fs
.readFileStringSafe(path)
.pipe(Effect.map((content) => (content === undefined ? undefined : { path, content }))),
{ concurrency: "unbounded" },
yield* Effect.gen(function* () {
const alreadyInjected = yield* previouslyInjected(store, input.sessionID)
const toInject = claimed.filter((path) => !alreadyInjected.has(path))
if (toInject.length === 0) return
const files = yield* Effect.forEach(
toInject,
(path) =>
fs
.readFileStringSafe(path)
.pipe(Effect.map((content) => (content === undefined ? undefined : { path, content }))),
{ concurrency: "unbounded" },
)
const readable = files.filter((file): file is { path: string; content: string } => file !== undefined)
if (readable.length === 0) return
// Publish directly rather than through Session.synthetic: a Location-scoped layer
// cannot depend on Session (it routes through LocationServiceMap, forming a type
// cycle with this node). The durable publish commits the synthetic and its metadata
// ledger atomically, so releasing the claim afterwards cannot readmit the paths.
yield* bus.publish(SessionEvent.Synthetic, {
sessionID: input.sessionID,
text: readable.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n"),
description: `Loaded ${readable.map((file) => describePath(root, file.path)).join(", ")}`,
metadata: { instruction: { paths: readable.map((file) => file.path) } },
})
}).pipe(
Effect.ensuring(
Ref.update(inFlight, (map) => {
const existing = map.get(input.sessionID)
if (!existing) return map
const remaining = new Set([...existing].filter((path) => !claimed.includes(path)))
const next = new Map(map)
if (remaining.size === 0) next.delete(input.sessionID)
else next.set(input.sessionID, remaining)
return next
}),
),
)
const readable = files.filter((file): file is { path: string; content: string } => file !== undefined)
if (readable.length === 0) return
// Publish directly rather than through Session.synthetic: a Location-scoped layer
// cannot depend on Session (it routes through LocationServiceMap, forming a type
// cycle with this node). The durable publish is what makes the synthetic visible on
// the next projected history reload. The dedup ledger lives on the synthetic message
// metadata so it survives across Location layer restarts.
yield* bus.publish(SessionEvent.Synthetic, {
sessionID: input.sessionID,
text: readable.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n"),
description: `Loaded ${readable.map((file) => describePath(root, file.path)).join(", ")}`,
metadata: { instruction: { paths: readable.map((file) => file.path) } },
})
})
return Service.of({ load })
+26 -32
View File
@@ -42,18 +42,18 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
const updateOwnedAssistant = (messageID: SessionMessage.ID, recipe: (draft: DraftAssistant) => void) =>
Effect.gen(function* () {
const assistant = yield* adapter.getAssistant(messageID)
if (assistant) yield* adapter.updateAssistant(produce(assistant, recipe))
if (!assistant) return
yield* adapter.updateAssistant(produce(assistant, recipe))
})
const clearCurrentRetry = Effect.gen(function* () {
const assistant = yield* adapter.getCurrentAssistant()
if (assistant?.retry) {
yield* adapter.updateAssistant(
produce(assistant, (draft) => {
draft.retry = undefined
}),
)
}
if (!assistant?.retry) return
yield* adapter.updateAssistant(
produce(assistant, (draft) => {
draft.retry = undefined
}),
)
})
const project = pipe(
@@ -61,8 +61,8 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
Match.discriminatorsExhaustive("type")({
"session.created": () => Effect.void,
"session.usage.recorded": () => Effect.void,
"session.agent.selected": (event) => {
return Effect.gen(function* () {
"session.agent.selected": (event) =>
Effect.gen(function* () {
const previous = event.data.previous ?? (yield* adapter.getAgent())
yield* adapter.appendMessage(
SessionMessage.AgentSelected.make({
@@ -74,10 +74,9 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
time: { created },
}),
)
})
},
"session.model.selected": (event) => {
return Effect.gen(function* () {
}),
"session.model.selected": (event) =>
Effect.gen(function* () {
const previous = event.data.previous ?? (yield* adapter.getModel())
yield* adapter.appendMessage(
SessionMessage.ModelSelected.make({
@@ -89,10 +88,9 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
time: { created },
}),
)
})
},
"session.moved": (event) => {
return Effect.gen(function* () {
}),
"session.moved": (event) =>
Effect.gen(function* () {
yield* adapter.appendMessage(
SessionMessage.LocationSwitched.make({
id: SessionMessage.ID.fromEvent(event.id),
@@ -105,8 +103,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
time: { created },
}),
)
})
},
}),
"session.renamed": () => Effect.void,
"session.deleted": () => Effect.void,
"session.forked": () => Effect.void,
@@ -169,8 +166,8 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
}),
)
},
"session.shell.ended": (event) => {
return Effect.gen(function* () {
"session.shell.ended": (event) =>
Effect.gen(function* () {
const currentShell = yield* adapter.getShell(event.data.shell.id)
if (currentShell) {
yield* adapter.updateShell(
@@ -182,10 +179,9 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
}),
)
}
})
},
"session.step.started": (event) => {
return Effect.gen(function* () {
}),
"session.step.started": (event) =>
Effect.gen(function* () {
const existing = yield* adapter.getAssistant(event.data.assistantMessageID)
if (existing) {
yield* adapter.updateAssistant(
@@ -224,8 +220,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
}),
)
})
},
}),
"session.step.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.time.completed = created
@@ -399,8 +394,8 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
time: { created },
}),
),
"session.compaction.ended": (event) => {
return Effect.gen(function* () {
"session.compaction.ended": (event) =>
Effect.gen(function* () {
const current = yield* adapter.getCompaction()
if (current?.status === "running") {
yield* adapter.updateCompaction({
@@ -424,8 +419,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
time: { created },
}),
)
})
},
}),
"session.compaction.failed": (event) =>
Effect.gen(function* () {
const current = yield* adapter.getCompaction()
+18 -14
View File
@@ -7,7 +7,6 @@ import { Cause, Config, Context, Effect, Layer, Result } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { App } from "../app.js"
import { Model } from "../model.js"
import { Provider } from "../provider.js"
import { Permission } from "../permission.js"
import { PluginHooks } from "../plugin/hooks.js"
import { QuestionTool } from "../tool/plugin/question.js"
@@ -29,6 +28,9 @@ const IMAGE_BYTES_TARGET = 15 * 1024 * 1024 // 15 MiB
const IMAGE_REMOVED =
"[This image was removed to reduce the request size and is no longer visible. Do not make claims about its contents from memory. If needed, retrieve it again with an available tool or ask the user to attach it again.]"
const responsesWebSocketFlag = (providerID: string) =>
`OPENCODE_EXPERIMENTAL_${providerID.replace(/[^a-zA-Z0-9]+/g, "_").toUpperCase()}_RESPONSES_WEBSOCKET`
/** Failures a prepared execution can surface: infrastructure errors plus user declines resurfaced from the defect tunnel. */
export type ExecuteError = Tool.Error | Permission.DeclinedError | QuestionTool.CancelledError
@@ -208,10 +210,6 @@ export const layer = Layer.effect(
const hooks = yield* PluginHooks.Service
const transport = yield* SessionModelTransport.Service
const app = yield* App.Metadata
const webSocket = yield* Config.boolean("OPENCODE_EXPERIMENTAL_OPENAI_RESPONSES_WEBSOCKET").pipe(
Config.withDefault(false),
Effect.orDie,
)
const prepare = Effect.fn("SessionModelRequest.prepare")(function* (input: PrepareInput) {
const session = input.scope.session
const resolved = input.scope.model
@@ -268,23 +266,29 @@ export const layer = Layer.effect(
toolChoice: input.toolChoice,
}),
)
const webSocketEligible =
!(yield* hooks.has("session", "http.request", resolved.ref.providerID)) &&
!(yield* hooks.has("session", "http.response", resolved.ref.providerID))
const http = webSocketEligible
? undefined
: SessionModelHttp.middleware(hooks, {
const hasHttpHooks =
(yield* hooks.has("session", "http.request", resolved.ref.providerID)) ||
(yield* hooks.has("session", "http.response", resolved.ref.providerID))
const webSocket =
resolved.capabilities.responsesWebsockets === true
? yield* Config.boolean(responsesWebSocketFlag(resolved.ref.providerID)).pipe(
Config.withDefault(false),
Effect.orDie,
)
: false
const http = hasHttpHooks
? SessionModelHttp.middleware(hooks, {
sessionID: session.id,
agent: input.scope.agentID,
model: resolved.ref,
})
: undefined
const options: StreamOptions = {
...(http ? { http } : {}),
...(input.webSocket === "session" &&
webSocket &&
webSocketEligible &&
resolved.ref.providerID === Provider.ID.openai &&
request.model.route.id === "openai-responses"
!hasHttpHooks &&
resolved.capabilities.responsesWebsockets === true
? { webSocket: transport.bind(session.id) }
: {}),
}
+13 -5
View File
@@ -3,6 +3,7 @@ export * as SessionRunnerLLM from "./llm.js"
import {
LLMClient,
AIError,
InvalidProviderOutputReason,
LLMEvent,
Message,
isContextOverflowFailure,
@@ -369,9 +370,6 @@ const layer = Layer.effect(
toolChoice: stepLimitReached ? "none" : undefined,
webSocket: "session",
})
const userRequests = loaded.messages
.findLast((message) => message.type === "user")
?.skills?.map((skill) => ({ action: "skill", resource: skill.id }))
yield* diagnosePromptCache(session.id, prepared.request)
const executeTool = (input: Parameters<typeof prepared.executeTool>[0]) => {
if (stepLimitReached) return new Tool.Error({ message: "Tools are disabled after the maximum agent steps" })
@@ -460,7 +458,6 @@ const layer = Layer.effect(
agent: agent.id,
messageID: assistantMessageID,
call: event,
userRequests,
// Progress is ephemeral, not durable history: nothing to order.
progress: (update) => publisher.progress(event.id, update),
}),
@@ -518,7 +515,18 @@ const layer = Layer.effect(
if (overflowFailure) yield* publisher.publish(overflowFailure)
// A thrown LLM failure not already recorded as the provider error either
// escapes as a scheduled retry or fails the assistant durably.
const llmFailure = streamFailure instanceof AIError ? streamFailure : undefined
const unknownFinish =
stream._tag === "Success" && publisher.record().finish?.finish === "unknown"
? new AIError({
module: "session",
method: "stream",
reason: new InvalidProviderOutputReason({
classification: "incomplete-stream",
message: "The provider response ended with an unknown finish reason.",
}),
})
: undefined
const llmFailure = streamFailure instanceof AIError ? streamFailure : unknownFinish
const llmError = llmFailure && !publisher.record().providerFailed ? toSessionError(llmFailure) : undefined
if (
recoverContinuation &&
@@ -84,17 +84,15 @@ const hostedContent = (result: ToolResultValue): NonEmptyContent => {
*/
export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, input: Input) => {
const deltaBatchInterval = 100
const tools = new Map<
string,
{
readonly assistantMessageID: SessionMessage.ID
readonly name: string
called: boolean
settled: boolean
providerExecuted: boolean
progress?: Tool.Metadata
}
>()
type ToolState = {
readonly assistantMessageID: SessionMessage.ID
readonly name: string
called: boolean
settled: boolean
providerExecuted: boolean
progress?: Tool.Metadata
}
const tools = new Map<string, ToolState>()
const failureSnapshot = (tool: { readonly progress?: Tool.Metadata }, metadata?: Tool.Metadata) => {
if (tool.progress === undefined) return metadata === undefined ? {} : { metadata }
if (metadata === undefined) return { metadata: tool.progress }
@@ -263,13 +261,14 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
}) {
if (tools.has(event.id)) return yield* Effect.die(new Error(`Duplicate tool input start: ${event.id}`))
const assistantMessageID = yield* startAssistant()
tools.set(event.id, {
const tool: ToolState = {
assistantMessageID,
name: event.name,
called: false,
settled: false,
providerExecuted: event.providerExecuted === true,
})
}
tools.set(event.id, tool)
yield* toolInput.start(event.id)
yield* bus.publish(SessionEvent.Tool.Input.Started, {
sessionID: input.sessionID,
@@ -277,6 +276,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
id: event.id,
name: event.name,
})
return tool
})
const endToolInput = Effect.fnUntraced(function* (
@@ -296,9 +296,8 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
readonly name: string
readonly raw: string
}) {
if (!tools.has(event.id)) yield* startToolInput(event)
const tool = tools.get(event.id)
if (!tool || tool.called || tool.settled)
const tool = tools.get(event.id) ?? (yield* startToolInput(event))
if (tool.called || tool.settled)
return yield* Effect.die(new Error(`Malformed tool input after call settlement: ${event.id}`))
if (tool.name !== event.name)
return yield* Effect.die(new Error(`Tool input name changed for ${event.id}: ${tool.name} -> ${event.name}`))
@@ -443,8 +442,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
return
case "tool-call": {
outputStarted = true
if (!tools.has(event.id)) yield* startToolInput(event)
const tool = tools.get(event.id)!
const tool = tools.get(event.id) ?? (yield* startToolInput(event))
if (toolInput.has(event.id)) yield* endToolInput(event)
if (tool.name !== event.name)
return yield* Effect.die(new Error(`Tool call name changed for ${event.id}: ${tool.name} -> ${event.name}`))
+1 -3
View File
@@ -58,9 +58,7 @@ const layer = Layer.effect(
const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie)
return row ? fromRow(row) : undefined
}),
context: Effect.fn("SessionStore.context")(function* (sessionID) {
return yield* SessionHistory.load(db, sessionID)
}),
context: Effect.fn("SessionStore.context")((sessionID) => SessionHistory.load(db, sessionID)),
message: Effect.fn("SessionStore.message")(function* (messageID) {
const row = yield* db
.select()
+1 -1
View File
@@ -19,7 +19,7 @@ export const EmbeddedSource = Skill.EmbeddedSource
export type EmbeddedSource = Skill.EmbeddedSource
export const Source = Skill.Source
export type Source = typeof Source.Type
export type Source = Skill.Source
export const Info = Skill.Info
export type Info = Skill.Info
+1 -1
View File
@@ -157,7 +157,7 @@ const layer = Layer.effect(
const current =
version === undefined
? undefined
: yield* fs.readFileStringSafe(versionFile).pipe(Effect.catch(() => Effect.succeed(undefined)))
: yield* fs.readFileStringSafe(versionFile).pipe(Effect.orElseSucceed(() => undefined))
if (version === undefined || current === version) {
yield* Effect.forEach(files, (file) => download(file.url, file.destination), {
concurrency: fileConcurrency,
+1 -2
View File
@@ -72,8 +72,7 @@ const layer = Layer.effect(
load: Effect.fn("SkillInstructions.load")(function* (selection) {
const agent = selection.info
if (!agent) return Instructions.empty
const permitted = Skill.available(yield* skills.list(), agent)
const available = permitted
const available = Skill.available(yield* skills.list(), agent)
.flatMap((skill) =>
skill.description === undefined || skill.autoinvoke === false
? []
+10 -12
View File
@@ -15,6 +15,10 @@ export interface Registration {
readonly dispose: Effect.Effect<void>
}
/**
* Registers and applies a scoped transform. Closing the owning Scope removes
* the transform and reloads the materialized state.
*/
export type Transform<DraftApi> = (
transform: TransformCallback<DraftApi>,
) => Effect.Effect<Registration, never, Scope.Scope>
@@ -69,10 +73,6 @@ export interface Options<State, DraftApi> {
export interface Interface<State, DraftApi> extends Transformable<DraftApi> {
readonly get: () => State
/**
* Registers and applies a scoped transform. Closing the owning Scope removes
* the transform and reloads the materialized state.
*/
}
export function create<State, DraftApi>(options: Options<State, DraftApi>): Interface<State, DraftApi> {
@@ -89,15 +89,14 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
if (options.finalize) yield* options.finalize(options.draft(next))
})
const apply = (transform: TransformCallback<DraftApi>, draft: DraftApi) =>
Effect.sync(() => {
transform(draft)
})
const materialize = Effect.fnUntraced(function* () {
const next = options.initial()
const api = options.draft(next)
for (const transform of transforms) yield* apply(transform.run, api)
for (const transform of transforms) {
yield* Effect.sync(() => {
transform.run(api)
})
}
yield* commit(next)
})
@@ -135,7 +134,7 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
return yield* Deferred.await(done)
})
const result: Interface<State, DraftApi> = {
return {
get: () => state,
transform: Effect.fn("State.transform")(function* (update) {
yield* Effect.annotateCurrentSpan("state", options.name ?? "anonymous")
@@ -176,5 +175,4 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
}),
reload,
}
return result
}
-3
View File
@@ -37,7 +37,6 @@ export interface Snapshot {
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
readonly call: ToolCall
readonly userRequests?: Tool.Context["userRequests"]
readonly progress?: (update: Tool.Metadata) => Effect.Effect<void>
}) => Effect.Effect<Tool.Result & { readonly content: ReadonlyArray<Tool.Content> }, Tool.Error>
}
@@ -235,7 +234,6 @@ const layer = Layer.effect(
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
readonly call: ToolCall
readonly userRequests?: Tool.Context["userRequests"]
readonly progress?: (update: Tool.Metadata) => Effect.Effect<void>
}) => {
const context: Tool.Context = {
@@ -243,7 +241,6 @@ const layer = Layer.effect(
agent: input.agent,
messageID: input.messageID,
id: Tool.CallID.make(input.call.id),
...(input.userRequests?.length ? { userRequests: input.userRequests } : {}),
progress: input.progress ?? (() => Effect.void),
}
if (input.call.name === "execute" && codemodeTool)
+8 -9
View File
@@ -51,15 +51,14 @@ export const Plugin = {
const skill = current.find((skill) => skill.id === input.id)
if (!skill) return yield* unableToLoad(input.id)
return yield* Effect.gen(function* () {
if (!context.userRequests?.some((request) => request.action === name && request.resource === skill.id))
yield* permission.assert({
action: name,
resources: [skill.id],
save: [skill.id],
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.messageID, id: context.id },
})
yield* permission.assert({
action: name,
resources: [skill.id],
save: [skill.id],
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.messageID, id: context.id },
})
const directory = path.dirname(skill.location)
const files =
path.basename(skill.location) === "SKILL.md"
+2 -2
View File
@@ -39,7 +39,7 @@ export function make(
if (item.code === "?") {
const content = yield* fs
.readFileString(path.join(input.worktree, item.file))
.pipe(Effect.catch(() => Effect.succeed(undefined)))
.pipe(Effect.orElseSucceed(() => undefined))
if (content === undefined || Buffer.byteLength(content) > MAX_PATCH_BYTES) return emptyPatch(item.file)
return addPatch(item.file, content)
}
@@ -142,7 +142,7 @@ function makeHg(proc: AppProcess.Interface, worktree: string) {
truncated: result.stdoutTruncated || result.stderrTruncated,
}
},
Effect.catch(() => Effect.succeed({ exitCode: 1, text: () => "", truncated: false })),
Effect.orElseSucceed(() => ({ exitCode: 1, text: () => "", truncated: false })),
)
const status = Effect.fn("VcsHg.statusNames")(function* (rev: string | undefined, scope: string) {
+12 -16
View File
@@ -106,6 +106,10 @@ const layer = Layer.effect(
const bus = yield* Bus.Service
const cache = yield* Ref.make(new Map<string, Entry>())
const lock = Semaphore.makeUnsafe(1)
const loadEntry = Effect.fn("WellKnown.loadEntry")(function* (origin: string) {
const manifest = yield* inspect(origin).pipe(Effect.provideService(HttpClient.HttpClient, http))
return { origin, integrationID: Integration.ID.make(origin), manifest }
})
const load = Effect.fn("WellKnown.load")(function* () {
const value = yield* kv.get(sourcesKey)
@@ -114,10 +118,7 @@ const layer = Layer.effect(
const entries = yield* Effect.forEach(origins, (origin) => {
const cached = current.get(origin)
if (cached) return Effect.succeed(cached)
return inspect(origin).pipe(
Effect.provideService(HttpClient.HttpClient, http),
Effect.map((manifest) => ({ origin, integrationID: Integration.ID.make(origin), manifest })),
)
return loadEntry(origin)
})
yield* Ref.set(cache, new Map(entries.map((entry) => [entry.origin, entry])))
return entries
@@ -129,12 +130,7 @@ const layer = Layer.effect(
const value = yield* kv.get(sourcesKey)
const origins = Schema.is(Sources)(value) ? value : []
if (!origins.length) return false
const entries = yield* Effect.forEach(origins, (origin) =>
inspect(origin).pipe(
Effect.provideService(HttpClient.HttpClient, http),
Effect.map((manifest) => ({ origin, integrationID: Integration.ID.make(origin), manifest })),
),
)
const entries = yield* Effect.forEach(origins, loadEntry)
const next = new Map(entries.map((entry) => [entry.origin, entry]))
const changed = !isDeepStrictEqual(Ref.getUnsafe(cache), next)
if (!changed) return false
@@ -153,9 +149,9 @@ const layer = Layer.effect(
return yield* lock.withPermit(
Effect.gen(function* () {
const origin = value.replace(/\/+$/, "")
const manifest = yield* inspect(origin).pipe(Effect.provideService(HttpClient.HttpClient, http))
if (!manifest.auth) return yield* Effect.fail(new Error(`No authentication method found at ${origin}`))
const entry = { origin, integrationID: Integration.ID.make(origin), manifest }
const entry = yield* loadEntry(origin)
if (!entry.manifest.auth)
return yield* Effect.fail(new Error(`No authentication method found at ${origin}`))
const sources = yield* kv.get(sourcesKey)
const origins = Schema.is(Sources)(sources) ? sources : []
yield* kv.set(sourcesKey, Array.from(new Set([...origins, origin])))
@@ -184,9 +180,9 @@ const layer = Layer.effect(
}),
)
}),
resolve: Effect.fn("WellKnown.resolveEntry")(function* (entry, variables) {
return yield* resolveEntry(entry, variables).pipe(Effect.provideService(HttpClient.HttpClient, http))
}),
resolve: Effect.fn("WellKnown.resolveEntry")((entry, variables) =>
resolveEntry(entry, variables).pipe(Effect.provideService(HttpClient.HttpClient, http)),
),
})
}),
)
+29 -28
View File
@@ -180,33 +180,33 @@ const layer = Layer.effect(
.get()
.pipe(Effect.orDie)
}),
create: Effect.fnUntraced(function* (input: StoredInput, tx?: Transaction) {
return (
(yield* (tx ?? db)
.insert(WorktreeTable)
.values({ project_id: input.projectID, directory: input.directory, strategy: input.strategy })
.onConflictDoUpdate({
target: [WorktreeTable.project_id, WorktreeTable.directory],
set: { strategy: input.strategy ?? null },
setWhere: input.strategy
? or(isNull(WorktreeTable.strategy), ne(WorktreeTable.strategy, input.strategy))
: isNotNull(WorktreeTable.strategy),
})
.returning({ directory: WorktreeTable.directory })
.get()
.pipe(Effect.orDie)) !== undefined
)
}),
remove: Effect.fnUntraced(function* (projectID: ProjectSchema.ID, directory: AbsolutePath, tx?: Transaction) {
return (
(yield* (tx ?? db)
.delete(WorktreeTable)
.where(and(eq(WorktreeTable.project_id, projectID), eq(WorktreeTable.directory, directory)))
.returning({ directory: WorktreeTable.directory })
.get()
.pipe(Effect.orDie)) !== undefined
)
}),
create: (input: StoredInput, tx?: Transaction) =>
(tx ?? db)
.insert(WorktreeTable)
.values({ project_id: input.projectID, directory: input.directory, strategy: input.strategy })
.onConflictDoUpdate({
target: [WorktreeTable.project_id, WorktreeTable.directory],
set: { strategy: input.strategy ?? null },
setWhere: input.strategy
? or(isNull(WorktreeTable.strategy), ne(WorktreeTable.strategy, input.strategy))
: isNotNull(WorktreeTable.strategy),
})
.returning({ directory: WorktreeTable.directory })
.get()
.pipe(
Effect.orDie,
Effect.map((row) => row !== undefined),
),
remove: (projectID: ProjectSchema.ID, directory: AbsolutePath, tx?: Transaction) =>
(tx ?? db)
.delete(WorktreeTable)
.where(and(eq(WorktreeTable.project_id, projectID), eq(WorktreeTable.directory, directory)))
.returning({ directory: WorktreeTable.directory })
.get()
.pipe(
Effect.orDie,
Effect.map((row) => row !== undefined),
),
}
const registry = new Map<StrategyID, Strategy>()
@@ -269,7 +269,8 @@ const layer = Layer.effect(
const worktreeDirectory = yield* canonical(fs, input.directory)
const stored = yield* ops.find(input.projectID, worktreeDirectory)
if (!stored?.strategy) return yield* new InvalidDirectoryError({ directory: worktreeDirectory })
yield* (yield* getStrategy(StrategyID.make(stored.strategy))).remove({
const strategy = yield* getStrategy(StrategyID.make(stored.strategy))
yield* strategy.remove({
directory: worktreeDirectory,
force: input.force,
})
+28
View File
@@ -113,6 +113,34 @@ it.effect("projects request settings, headers, and body overlays", () =>
}),
)
it.effect("uses only the provider timeout signal when the request signal is null", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
let wrappedFetch: typeof fetch | undefined
let requestSignal: AbortSignal | null | undefined
yield* aisdk.hook.sdk((event) => {
wrappedFetch = event.options.fetch
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
})
yield* aisdk.language(
model("test-ai-sdk", {
timeout: 60_000,
fetch: async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
requestSignal = init?.signal
return new Response()
},
}),
)
const request = wrappedFetch
if (!request) return yield* Effect.die("Expected wrapped fetch")
yield* Effect.promise(() => request("https://example.com", { signal: null }))
expect(requestSignal).toBeInstanceOf(AbortSignal)
expect(requestSignal?.aborted).toBeFalse()
}),
)
it.effect("lowers chronological system updates to wrapped user messages", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
+8 -3
View File
@@ -31,6 +31,11 @@ import { testEffect } from "../lib/effect"
const it = testEffect(Layer.empty)
const selection = Schema.decodeUnknownSync(ConfigModel.Selection)
function inFixture(root: string, target: string) {
const relative = path.relative(root, target)
return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative))
}
function testLayer(
directory: string,
globalDirectory = path.join(directory, "global"),
@@ -801,7 +806,7 @@ describe("Config", () => {
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const config = yield* Config.Service
const entries = yield* config.entries()
const entries = (yield* config.entries()).filter((entry) => !entry.path || inFixture(tmp.path, entry.path))
expect(entries).toEqual([
new Directory({ type: "directory", path: AbsolutePath.make(path.join(tmp.path, "global")) }),
@@ -861,7 +866,7 @@ describe("Config", () => {
const watcher = yield* Watcher.Test
yield* config.entries()
expect(yield* watcher.subscriptions()).toEqual([
expect((yield* watcher.subscriptions()).filter((item) => inFixture(tmp.path, item.path))).toEqual([
{
type: "directory",
path: AbsolutePath.make(path.join(tmp.path, "global")),
@@ -1506,7 +1511,7 @@ describe("Config", () => {
return yield* Effect.gen(function* () {
const config = yield* Config.Service
const entries = yield* config.entries()
const entries = (yield* config.entries()).filter((entry) => !entry.path || inFixture(tmp.path, entry.path))
const documents = entries.filter((entry) => entry.type === "document")
expect(entries.filter((entry) => entry.type === "directory").map((entry) => entry.path)).toEqual([
@@ -242,6 +242,53 @@ describe("AzurePlugin", () => {
),
)
it.effect("marks only Azure v1 Responses deployments as WebSocket capable", () =>
withEnv({ AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const models = {
responses: Model.ID.make("responses"),
chat: Model.ID.make("chat"),
preview: Model.ID.make("preview"),
deploymentURL: Model.ID.make("deployment-url"),
gateway: Model.ID.make("gateway"),
nonAzure: Model.ID.make("non-azure"),
}
yield* catalog.transform((draft) => {
draft.provider.update(Provider.ID.azure, (provider) => {
provider.package = Provider.aisdk("@ai-sdk/azure")
})
draft.model.update(Provider.ID.azure, models.responses, () => {})
draft.model.update(Provider.ID.azure, models.chat, (model) => {
model.settings = { useCompletionUrls: true }
})
draft.model.update(Provider.ID.azure, models.preview, (model) => {
model.settings = { apiVersion: "2025-04-01-preview" }
})
draft.model.update(Provider.ID.azure, models.deploymentURL, (model) => {
model.settings = { useDeploymentBasedUrls: true }
})
draft.model.update(Provider.ID.azure, models.gateway, (model) => {
model.settings = { baseURL: "https://gateway.example/azure" }
})
draft.model.update(Provider.ID.azure, models.nonAzure, (model) => {
model.package = Provider.aisdk("@ai-sdk/anthropic")
})
})
yield* addPlugin()
expect(
required(yield* catalog.model.get(Provider.ID.azure, models.responses)).capabilities.responsesWebsockets,
).toBe(true)
for (const modelID of [models.chat, models.preview, models.deploymentURL, models.gateway, models.nonAzure])
expect(
required(yield* catalog.model.get(Provider.ID.azure, modelID)).capabilities.responsesWebsockets,
).toBeUndefined()
}),
),
)
it.effect("rejects missing resourceName when baseURL is not configured", () =>
withEnv({ AZURE_RESOURCE_NAME: undefined }, () =>
Effect.gen(function* () {
@@ -197,6 +197,7 @@ describe("OpenAIPlugin", () => {
expect(model.package).toBe(Provider.aisdk("@ai-sdk/openai"))
expect(model.enabled).toBe(true)
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
expect(model.capabilities.responsesWebsockets).toBe(true)
expect(direct.headers).not.toHaveProperty("originator")
expect(direct.hasHttpHooks).toBe(false)
expect(provider.headers).not.toHaveProperty("originator")
@@ -204,7 +205,7 @@ describe("OpenAIPlugin", () => {
}),
)
it.effect("selects WebSocket with the built-in provider hooks enabled", () =>
it.effect("selects Azure WebSocket from capability and the Azure flag only", () =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
yield* credentials.create({
@@ -221,8 +222,12 @@ describe("OpenAIPlugin", () => {
})
const sessionID = Session.ID.make("ses_websocket_hooks")
const agentID = Agent.ID.make("build")
const model = SessionRunnerModel.resolved(OpenAIResponses.route.model({ id: "gpt-5.5" }), {
capabilities: { tools: true, input: ["text"], output: ["text"] },
const route = OpenAIResponses.route.with({
id: "deployment-responses",
provider: Provider.ID.azure,
})
const model = SessionRunnerModel.resolved(route.model({ id: "gpt-5.5" }), {
capabilities: { tools: true, input: ["text"], output: ["text"], responsesWebsockets: true },
cost: [],
limit: { context: 200_000, output: 32_000 },
})
@@ -248,6 +253,16 @@ describe("OpenAIPlugin", () => {
}).pipe(
Effect.provide(SessionModelRequest.layer),
Effect.provideService(SessionModelTransport.Service, transport),
)
const prepared = yield* program.pipe(
Effect.provide(
ConfigProvider.layer(
ConfigProvider.fromEnv({ env: { OPENCODE_EXPERIMENTAL_AZURE_RESPONSES_WEBSOCKET: "true" } }),
),
),
)
const otherProvider = yield* program.pipe(
Effect.provide(
ConfigProvider.layer(
ConfigProvider.fromEnv({ env: { OPENCODE_EXPERIMENTAL_OPENAI_RESPONSES_WEBSOCKET: "true" } }),
@@ -255,10 +270,9 @@ describe("OpenAIPlugin", () => {
),
)
const prepared = yield* program
expect(prepared.options.webSocket).toBe(executor)
expect(prepared.options.http).toBeUndefined()
expect(otherProvider.options.webSocket).toBeUndefined()
}),
)
})
@@ -1,10 +1,13 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { XAIPlugin } from "@opencode-ai/core/plugin/provider/xai"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
@@ -65,4 +68,23 @@ describe("XAIPlugin", () => {
})
}),
)
it.effect("marks xAI deployments as Responses WebSocket capable", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = Provider.ID.make("xai")
yield* catalog.transform((draft) => {
draft.provider.update(providerID, (provider) => {
provider.package = Provider.aisdk("@ai-sdk/xai")
})
draft.model.update(providerID, Model.ID.make("grok-4.6"), () => {})
})
yield* addPlugin()
expect((yield* catalog.model.get(providerID, Model.ID.make("grok-4.6")))?.capabilities.responsesWebsockets).toBe(
true,
)
}),
)
})
@@ -233,6 +233,37 @@ describe("SessionInstructions", () => {
}),
)
it.effect("re-injects nested instructions dropped from history by compaction", () =>
Effect.gen(function* () {
const location = yield* Location.Service
const dir = location.directory
const subPath = path.resolve(dir, "sub", "AGENTS.md")
yield* mkdir(path.resolve(dir, "sub"))
yield* writeAgents(path.resolve(dir, "AGENTS.md"), "root-instructions")
yield* writeAgents(subPath, "sub-instructions")
yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "sub", "file.txt"), "content"))
const session = yield* Session.Service
const registry = yield* Tool.Service
const bus = yield* Bus.Service
const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id
yield* executeTool(registry, readCall(sessionID, "call-before", "sub/file.txt"))
expect(yield* synthetics(sessionID)).toHaveLength(1)
// A completed compaction truncates model-visible history at its boundary, dropping
// the synthetic that carried sub's instructions.
yield* bus.publish(SessionEvent.Compaction.Started, { sessionID, reason: "manual", recent: "" })
yield* bus.publish(SessionEvent.Compaction.Ended, { sessionID, reason: "manual", text: "summary", recent: "" })
expect(yield* synthetics(sessionID)).toHaveLength(0)
// The model no longer has the rules, so the next read under the subtree must
// re-inject them rather than trusting a stale in-memory claim.
yield* executeTool(registry, readCall(sessionID, "call-after", "sub/file.txt"))
expect(yield* synthetics(sessionID)).toHaveLength(1)
}),
)
it.effect("listing the Location root directory injects no instructions", () =>
Effect.gen(function* () {
const location = yield* Location.Service
+58
View File
@@ -4519,6 +4519,31 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("retries an unknown finish before output", () =>
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Retry unknown finish")
yield* TestLLM.push([
LLMEvent.stepStart({ index: 0 }),
LLMEvent.stepFinish({ index: 0, reason: { normalized: "unknown" } }),
LLMEvent.finish({ reason: { normalized: "unknown" } }),
])
yield* TestLLM.push(TestLLM.text("Recovered", "unknown-finish-success"))
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* TestLLM.wait(1)
yield* TestClock.adjust("2400 millis")
yield* Fiber.join(run)
expect(requests).toHaveLength(2)
expect(yield* recordedEventTypes(sessionID)).toContain("session.retry.scheduled.1")
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user" },
{ type: "assistant", finish: "stop", content: [{ type: "text", text: "Recovered" }] },
])
}),
)
it.effect("uses a larger provider retry-after delay", () =>
Effect.gen(function* () {
const session = yield* setup
@@ -4594,6 +4619,39 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("continues an unknown finish after observable text", () =>
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Continue unknown finish")
yield* TestLLM.push([
LLMEvent.stepStart({ index: 0 }),
LLMEvent.textStart({ id: "unknown-partial" }),
LLMEvent.textDelta({ id: "unknown-partial", text: "Partial" }),
LLMEvent.textEnd({ id: "unknown-partial" }),
LLMEvent.stepFinish({ index: 0, reason: { normalized: "unknown" } }),
LLMEvent.finish({ reason: { normalized: "unknown" } }),
])
yield* TestLLM.push(TestLLM.text(" continuation", "unknown-continuation"))
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* TestLLM.wait(1)
yield* TestClock.adjust("2400 millis")
yield* Fiber.join(run)
expect(requests).toHaveLength(2)
expect(requests[1]?.messages.at(-1)).toMatchObject({
role: "user",
content: [{ type: "text", text: INCOMPLETE_STREAM_CONTINUATION }],
})
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user" },
{ type: "assistant", finish: "error", content: [{ type: "text", text: "Partial" }] },
{ type: "synthetic", text: INCOMPLETE_STREAM_CONTINUATION },
{ type: "assistant", finish: "stop", content: [{ type: "text", text: " continuation" }] },
])
}),
)
it.effect("lowers interrupted reasoning before continuing an incomplete stream", () =>
Effect.gen(function* () {
const session = yield* setup
-13
View File
@@ -131,25 +131,12 @@ describe("SkillTool", () => {
yield* executeTool(registry, {
sessionID,
...toolIdentity,
userRequests: [{ action: "skill", resource: "other" }],
call: { type: "tool-call", id: "call-denied-skill", name: "skill", input: { id: "effect" } },
}),
).toEqual({
status: "error",
error: { type: "permission.rejected", message: "Permission denied: skill" },
})
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
userRequests: [{ action: "skill", resource: "effect" }],
call: { type: "tool-call", id: "call-user-skill", name: "skill", input: { id: "effect" } },
}),
).toMatchObject({
status: "completed",
content: [{ type: "text", text: Skill.toModelOutput(info, [reference]) }],
})
expect(assertions).toHaveLength(3)
deny = false
const flat = Skill.Info.make({
id: Skill.ID.make("public"),
@@ -5,6 +5,7 @@ import { AppRpcs } from "../../shared/ipc-rpc"
import { openExternalURL } from "../files"
import { checkAppExists, resolveAppPath } from "../files/apps"
import { setForceFocus } from "../native/debug"
import { showCliInstaller } from "../native/install-cli"
import { DesktopLogging, scoped } from "../native/logging"
import { createMenu, sendMenuCommand } from "../native/menu"
import { setNativeTranslations } from "../native/translations"
@@ -12,6 +13,7 @@ import { IpcPortHandoff } from "../ipc-transport"
import { ApplicationLifecycle } from "../lifecycle"
import { finishFirstLaunchOnboarding, isFirstLaunchOnboardingPending } from "../lifecycle/onboarding"
import { BackgroundService } from "../service/background-service"
import { DesktopCli } from "../service/desktop-cli"
import { getDefaultServerUrl, setDefaultServerUrl } from "../service/server-settings"
import { Updater } from "../updater"
import { getLastFocusedWindow, setBackgroundColor } from "../windows"
@@ -22,6 +24,7 @@ export const appHandlers = AppRpcs.toLayer(
const handoff = yield* IpcPortHandoff
const lifecycle = yield* ApplicationLifecycle.Service
const background = yield* BackgroundService.Service
const desktopCli = yield* DesktopCli.Service
const updater = yield* Updater.Service
const logging = yield* DesktopLogging.Service
const runFork = Effect.runForkWith(yield* Effect.context())
@@ -56,6 +59,7 @@ export const appHandlers = AppRpcs.toLayer(
if (win) sendMenuCommand(win, id)
},
checkForUpdates: () => runFork(updater.show),
installCli: () => runFork(showCliInstaller(desktopCli)),
createWindow: lifecycle.createWindow,
openExternal: (url) => runFork(openExternalURL(url)),
relaunch: lifecycle.relaunch,
+4
View File
@@ -16,7 +16,9 @@ import { windowHandlers } from "./ipc-handlers/window"
import { wslHandlers } from "./ipc-handlers/wsl"
import { IpcPortHandoff, IpcServerProtocolLive } from "./ipc-transport"
import { ApplicationLifecycle } from "./lifecycle"
import { showCliInstaller } from "./native/install-cli"
import { createMenu, sendMenuCommand } from "./native/menu"
import { DesktopCli } from "./service/desktop-cli"
import { DesktopStorage } from "./storage"
import { Updater } from "./updater"
import { getLastFocusedWindow } from "./windows"
@@ -42,6 +44,7 @@ export const layer = RpcServer.layer(DesktopRpcs, { disableFatalDefects: true })
export const registerIpcHandlers = Effect.gen(function* () {
const handoff = yield* IpcPortHandoff
const lifecycle = yield* ApplicationLifecycle.Service
const desktopCli = yield* DesktopCli.Service
const updater = yield* Updater.Service
const runFork = Effect.runForkWith(yield* Effect.context())
const menu = {
@@ -50,6 +53,7 @@ export const registerIpcHandlers = Effect.gen(function* () {
if (win) sendMenuCommand(win, id)
},
checkForUpdates: () => runFork(updater.show),
installCli: () => runFork(showCliInstaller(desktopCli)),
createWindow: lifecycle.createWindow,
openExternal: (url: string) => runFork(openExternalURL(url)),
relaunch: lifecycle.relaunch,
@@ -0,0 +1,28 @@
import { dialog } from "electron"
import { Effect } from "effect"
import { DesktopCli } from "../service/desktop-cli"
import { nativeT } from "./translations"
export function showCliInstaller(desktopCli: DesktopCli.Interface) {
return desktopCli.install.pipe(
Effect.tap((path) =>
Effect.promise(() =>
dialog.showMessageBox({
type: "info",
message: nativeT("desktop.cli.installed.message", { path }),
title: nativeT("desktop.cli.installed.title"),
}),
),
),
Effect.catch((error) =>
Effect.promise(() =>
dialog.showMessageBox({
type: "error",
message: nativeT("desktop.cli.failed.message", { error: error.message }),
title: nativeT("desktop.cli.failed.title"),
}),
),
),
Effect.asVoid,
)
}
@@ -4,6 +4,7 @@ import { updateTitlebar } from "../windows"
export type DesktopMenuActionHandlers = Partial<{
checkForUpdates: () => void
installCli: () => void
createWindow: () => void
relaunch: () => void
}>
@@ -17,6 +18,9 @@ export function runDesktopMenuAction(
case "app.checkForUpdates":
handlers.checkForUpdates?.()
return
case "app.installCli":
handlers.installCli?.()
return
case "app.relaunch":
handlers.relaunch?.()
return
+2
View File
@@ -16,6 +16,7 @@ import { nativeT } from "./translations"
type Deps = {
trigger: (id: string) => void
checkForUpdates: () => void
installCli: () => void
createWindow: () => void
openExternal: (url: string) => void
relaunch: () => void
@@ -60,6 +61,7 @@ function nativeItem(entry: DesktopMenuEntry, deps: Deps): MenuItemConstructorOpt
item.click = () =>
runDesktopMenuAction(BrowserWindow.getFocusedWindow(), action, {
checkForUpdates: deps.checkForUpdates,
installCli: deps.installCli,
createWindow: deps.createWindow,
relaunch: deps.relaunch,
})
@@ -1,9 +1,10 @@
export * as DesktopCli from "./desktop-cli"
import { execFile } from "node:child_process"
import { execFile, spawn } from "node:child_process"
import { promisify } from "node:util"
import { app } from "electron"
import { Context, Effect, FileSystem, Layer, Path } from "effect"
import installer from "../../../../../install?raw"
import { DesktopPaths } from "../paths"
import { parseCliVersion } from "./cli-version"
@@ -18,6 +19,7 @@ export interface Resolved {
export interface Interface {
readonly resolve: Effect.Effect<Resolved>
readonly install: Effect.Effect<string, Error>
}
export class Service extends Context.Service<Service, Interface>()("opencode/desktop/DesktopCli") {}
@@ -25,10 +27,19 @@ export class Service extends Context.Service<Service, Interface>()("opencode/des
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const path = yield* Path.Path
const resolve = yield* Effect.cached(
make().pipe(Effect.provide(yield* Effect.context<FileSystem.FileSystem | Path.Path>()), Effect.orDie),
)
return Service.of({ resolve })
const install = Effect.gen(function* () {
if (process.platform !== "darwin") return yield* Effect.fail(new Error("CLI installation requires macOS"))
const cli = yield* resolve
if (!cli.binary) return yield* Effect.fail(new Error("Bundled CLI executable is unavailable"))
const home = app.getPath("home")
yield* runInstaller(cli.binary, home)
return path.join(home, ".opencode", "bin", "opencode2")
})
return Service.of({ resolve, install })
}),
)
@@ -134,6 +145,27 @@ const run = Effect.fn("DesktopCli.run")(function* (binary: string, args: string[
return stdout
})
const runInstaller = Effect.fn("DesktopCli.installForUser")(function* (binary: string, home: string) {
yield* Effect.tryPromise({
try: () =>
new Promise<void>((resolve, reject) => {
const child = spawn("/bin/bash", ["-s", "--", "--binary", binary], {
env: { ...process.env, HOME: home },
stdio: ["pipe", "ignore", "pipe"],
})
let stderr = ""
child.stderr.on("data", (chunk) => (stderr += chunk))
child.on("error", reject)
child.on("close", (code) => {
if (code === 0) return resolve()
reject(new Error(stderr.trim() || `CLI installer exited with code ${code}`))
})
child.stdin.end(installer)
}),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
})
})
function executableName() {
return process.platform === "win32" ? "opencode-cli.exe" : "opencode-cli"
}
@@ -3,6 +3,7 @@ import { Rpc, RpcGroup } from "effect/unstable/rpc"
const DesktopMenuAction = Schema.Literals([
"app.checkForUpdates",
"app.installCli",
"app.relaunch",
"edit.undo",
"edit.redo",
+1
View File
@@ -64,6 +64,7 @@ export const Capabilities = Schema.Struct({
tools: Schema.Boolean,
input: Schema.Array(Schema.String),
output: Schema.Array(Schema.String),
responsesWebsockets: Schema.Boolean.pipe(optional),
}).annotate({ identifier: "Model.Capabilities" })
export interface Cost extends Schema.Schema.Type<typeof Cost> {}
-1
View File
@@ -16,7 +16,6 @@ export interface Context {
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
readonly id: CallID
readonly userRequests?: ReadonlyArray<{ readonly action: string; readonly resource: string }>
readonly progress: (update: Metadata) => Effect.Effect<void>
}
+10
View File
@@ -58,3 +58,13 @@ describe("Model.Info", () => {
expect(model.limit).toEqual({ context: 200_000, output: 32_000 })
})
})
describe("Model.Capabilities", () => {
test("decodes optional Responses WebSocket support", () => {
const decode = Schema.decodeUnknownSync(Model.Capabilities)
const base = { tools: true, input: ["text"], output: ["text"] }
expect(decode(base)).toEqual(base)
expect(decode({ ...base, responsesWebsockets: true })).toEqual({ ...base, responsesWebsockets: true })
})
})
+158 -108
View File
@@ -19,6 +19,8 @@ import { useClipboard } from "../../context/clipboard"
import { Spinner } from "../spinner"
import { useClient } from "../../context/client"
import { useRoute } from "../../context/route"
import { usePromptRef } from "../../context/prompt"
import { useSessionTabs } from "../../context/session-tabs"
import { useEvent } from "../../context/event"
import { editorSelectionKey, useEditorContext, type EditorSelection } from "../../context/editor"
import { normalizePromptContent, openEditor } from "../../editor"
@@ -201,6 +203,8 @@ export function Prompt(props: PromptProps) {
const client = useClient()
const editor = useEditorContext()
const route = useRoute()
const promptRef = usePromptRef()
const sessionTabs = useSessionTabs()
const data = useData()
const directoryRecents = useDirectoryRecents()
const keymapCommands = Keymap.useCommands()
@@ -688,16 +692,20 @@ export function Prompt(props: PromptProps) {
input.gotoBufferEnd()
},
reset() {
input.clear()
input.extmarks.clear()
setStore("prompt", emptyPrompt())
setStore("extmarkToPart", new Map())
resetComposer()
},
submit() {
void submit()
},
}
function resetComposer() {
input.extmarks.clear()
setStore("prompt", emptyPrompt())
setStore("extmarkToPart", new Map())
input.clear()
}
// Captured once: the session route is keyed by sessionID, so this Prompt
// instance belongs to exactly one tab. Reading props.sessionID lazily would
// observe the *next* route during onCleanup and stash under the wrong tab.
@@ -873,10 +881,7 @@ export function Prompt(props: PromptProps) {
run: () => {
if (!store.prompt.text) return
stash.push({ prompt: store.prompt })
input.extmarks.clear()
input.clear()
setStore("prompt", emptyPrompt())
setStore("extmarkToPart", new Map())
resetComposer()
dialog.clear()
},
},
@@ -1168,102 +1173,139 @@ export function Prompt(props: PromptProps) {
return false
}
// Snapshot the composer and clear it synchronously, before the first await.
// Everything below reads the snapshot: text typed while a request is in
// flight lands in the already-empty composer and survives, and prompt
// history records exactly what was submitted instead of the live store
// (which may have absorbed mid-flight typing). Failure paths restore the
// snapshot unless the user has started typing something new.
const currentMode = store.mode
const entry = { ...store.prompt, mode: currentMode }
history.append(entry)
resetComposer()
props.onSubmit?.()
const restoreEntry = () => {
if (disposed || input.isDestroyed || input.plainText !== "") return
input.setText(entry.text)
setStore("prompt", entry)
setStore("mode", entry.mode ?? "normal")
restoreExtmarksFromPrompt(entry)
input.cursorOffset = entry.text.length
}
const variant = selection.variant
let sessionID = props.sessionID
let session = sessionID ? data.session.get(sessionID) : undefined
let finishMoveProgress = false
// New-session sends wait for creation and environment setup.
let newSession: { gate: Promise<unknown>; recover: (error: unknown) => void } | undefined
if (sessionID == null) {
const directory = await move.getDirectory()
if (move.pending() && !directory) return false
if (move.pending() && !directory) {
restoreEntry()
return false
}
finishMoveProgress = Boolean(move.progress())
// The location context is where the next session is created: seeded by the home
// route (launch cwd, inherited session location, or picked project) and updated
// by /cd before a session exists.
const location = currentLocation.ref ?? data.location.default()
const created = await client.api.session
.create({
location: directory ? { directory } : location,
agent: agent.id,
model: {
providerID: selection.providerID,
id: selection.modelID,
variant,
},
})
.catch(() => undefined)
if (!created) {
if (finishMoveProgress) move.finishSubmit()
toast.show({
message: "Creating a session failed. Open console for more details.",
variant: "error",
})
return true
}
// Optimistic create: the data layer mints the ID client-side and admits
// a local session record synchronously, so the navigation below happens
// immediately — enter feels sent even while the create round-trip is in
// flight. Sends against the new session gate on the request.
const created = data.session.create({
location: directory ? { directory } : location,
agent: agent.id,
model: {
providerID: selection.providerID,
id: selection.modelID,
variant,
},
})
sessionID = created.id
session = created
if (created.location.workspaceID === undefined && terminalEnvironment.variables !== undefined) {
const error = await client.api.session
.environment({ sessionID, variables: terminalEnvironment.variables })
.then(
() => undefined,
(error) => error,
)
if (error) {
if (finishMoveProgress) move.finishSubmit()
toast.show({ title: "Failed to set session environment", message: errorMessage(error), variant: "error" })
return true
}
session = data.session.get(created.id)
newSession = {
gate: created.request.then(async (info) => {
if (info.location.workspaceID === undefined && terminalEnvironment.variables !== undefined) {
await client.api.session.environment({ sessionID: created.id, variables: terminalEnvironment.variables })
}
}),
recover: (error) => {
toast.show({
title: data.session.get(created.id) ? "Failed to set up session" : "Creating a session failed",
message: errorMessage(error),
variant: "error",
})
const active =
route.data.type === "session" && route.data.sessionID === created.id ? promptRef.current : undefined
const current = active?.current
const draft = current?.text
? { prompt: { ...unwrap(current) }, cursor: current.text.length }
: (takeDraft(created.id) ?? { prompt: entry, cursor: entry.text.length })
saveDraft(undefined, draft)
active?.reset()
if (sessionTabs.enabled()) {
sessionTabs.close(created.id)
} else if (route.data.type === "session" && route.data.sessionID === created.id) {
route.navigate({ type: "home" })
}
},
}
}
// Capture mode before it gets reset
const currentMode = store.mode
if (store.mode === "shell") {
const target = sessionID
const dispatch = (send: () => Promise<unknown>) => {
const setup = newSession
if (setup) void setup.gate.then(send).catch(setup.recover)
else void send()
}
if (currentMode === "shell") {
move.startSubmit()
void client.api.session.shell({
sessionID,
command: inputText,
})
dispatch(() => client.api.session.shell({ sessionID: target, command: inputText }))
setStore("mode", "normal")
} else if (slashHead && isCommand) {
move.startSubmit()
const model = { providerID: selection.providerID, id: selection.modelID, variant }
const cancelCommit = local.model.trackSessionCommit(sessionID, model)
const cancelCommit = local.model.trackSessionCommit(target, model)
void client.api.session
.command({
sessionID,
const send = () =>
client.api.session.command({
sessionID: target,
command: slashHead.name,
arguments: slashHead.arguments,
agent: agent.id,
model,
files: store.prompt.files,
agents: store.prompt.agents,
skills: store.prompt.skills?.length ? store.prompt.skills : undefined,
files: entry.files,
agents: entry.agents,
skills: entry.skills?.length ? entry.skills : undefined,
delivery,
})
.catch((error) => {
cancelCommit()
toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" })
})
const setup = newSession
void (setup ? setup.gate.then(send) : send()).catch((error) => {
cancelCommit()
if (setup) return setup.recover(error)
toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" })
restoreEntry()
})
} else if (isSkill) {
move.startSubmit()
void client.api.session.skill({
sessionID,
skill: slashHead.name,
})
dispatch(() => client.api.session.skill({ sessionID: target, skill: slashHead.name }))
} else {
move.startSubmit()
if (!session) {
await data.session.sync(sessionID)
session = data.session.get(sessionID)
}
if (session?.agent !== agent.id) {
await client.api.session.switchAgent({ sessionID, agent: agent.id })
try {
if (!session) {
await data.session.sync(target)
session = data.session.get(target)
}
if (session?.agent !== agent.id) {
await client.api.session.switchAgent({ sessionID: target, agent: agent.id })
}
} catch (error) {
toast.show({ title: "Failed to prepare session", message: errorMessage(error), variant: "error" })
restoreEntry()
return true
}
if (
session?.model?.providerID !== selection.providerID ||
@@ -1271,83 +1313,94 @@ export function Prompt(props: PromptProps) {
(session.model.variant ?? "default") !== (variant ?? "default")
) {
const model = { providerID: selection.providerID, id: selection.modelID, variant }
const cancelCommit = local.model.trackSessionCommit(sessionID, model)
await client.api.session.switchModel({ sessionID, model }).catch((error) => {
const cancelCommit = local.model.trackSessionCommit(target, model)
const switchError = await client.api.session.switchModel({ sessionID: target, model }).then(
() => undefined,
(error) => error,
)
if (switchError) {
cancelCommit()
throw error
})
toast.show({ title: "Failed to switch model", message: errorMessage(switchError), variant: "error" })
restoreEntry()
return true
}
}
if (session?.revert) {
const error = await client.api.session.revert.commit({ sessionID }).then(
const error = await client.api.session.revert.commit({ sessionID: target }).then(
() => undefined,
(error) => error,
)
if (error) {
toast.show({ title: "Failed to commit revert", message: errorMessage(error), variant: "error" })
restoreEntry()
return false
}
}
if (pendingEditorSelection) {
// Keep editor context hidden while admitting it before the corresponding user prompt.
const error = await client.api.session
.synthetic({
sessionID,
const send = () =>
client.api.session.synthetic({
sessionID: target,
text: formatEditorContext(pendingEditorSelection),
resume: false,
})
.then(
if (newSession) {
// Fold into the setup gate so the context still admits before the
// user prompt once the session exists.
newSession.gate = newSession.gate.then(send)
} else {
const error = await send().then(
() => undefined,
(error) => error,
)
if (error) {
toast.show({ title: "Failed to send editor context", message: errorMessage(error), variant: "error" })
return false
if (error) {
toast.show({ title: "Failed to send editor context", message: errorMessage(error), variant: "error" })
restoreEntry()
return false
}
}
}
// The data layer admits optimistically: the prompt renders immediately
// and rolls back if the server rejects it, so submission does not wait
// on the network. On rejection the row is already rolled back; restore
// the composer unless the user has started typing something new.
const entry = { ...store.prompt, mode: currentMode }
data.session
.prompt({
sessionID,
sessionID: target,
text: inputText,
files: store.prompt.files,
agents: store.prompt.agents,
skills: store.prompt.skills?.length ? store.prompt.skills : undefined,
files: entry.files,
agents: entry.agents,
skills: entry.skills?.length ? entry.skills : undefined,
delivery,
gate: newSession?.gate,
})
.catch((error) => {
if (newSession) return newSession.recover(error)
toast.show({ title: "Failed to send prompt", message: errorMessage(error), variant: "error" })
if (disposed || input.isDestroyed || input.plainText !== "") return
input.setText(entry.text)
setStore("prompt", entry)
setStore("mode", entry.mode ?? "normal")
restoreExtmarksFromPrompt(entry)
input.cursorOffset = entry.text.length
restoreEntry()
})
if (pendingEditorSelection) editor.markSelectionSent()
}
history.append({
...store.prompt,
mode: currentMode,
})
input.extmarks.clear()
setStore("prompt", emptyPrompt())
setStore("extmarkToPart", new Map())
props.onSubmit?.()
// Optimistic admission puts the message in the store synchronously, so
// the session view renders it on arrival.
if (!props.sessionID) {
if (pendingEditorSelection) editor.preserveSelectionFromNewSession()
// Text typed while session creation was in flight lives in this (home)
// prompt, which unmounts on navigation and would stash it under the
// home key. Re-stash it under the new session so that composer restores
// it, and clear it here so onCleanup does not also stash it for home.
if (!disposed && !input.isDestroyed && store.prompt.text) {
// Copy before clearing: unwrap returns the live store target, and the
// resetComposer store write merges into that same object.
saveDraft(sessionID, { prompt: { ...unwrap(store.prompt) }, cursor: input.cursorOffset })
resetComposer()
}
route.navigate({
type: "session",
sessionID,
})
}
input.clear()
if (finishMoveProgress) move.finishSubmit()
return true
}
@@ -1509,10 +1562,7 @@ export function Prompt(props: PromptProps) {
mode: store.mode,
})
}
input.clear()
input.extmarks.clear()
setStore("prompt", emptyPrompt())
setStore("extmarkToPart", new Map())
resetComposer()
}
const highlight = createMemo(() => {
+10 -1
View File
@@ -116,7 +116,16 @@ export function createMarquee(animations: () => boolean) {
interval = undefined
}
const scroll = () => {
interval = setInterval(() => setOffset((value) => (value + 1) % cycleWidth), MARQUEE_INTERVAL)
interval = setInterval(
() =>
setOffset((value) => {
if (value + 1 < cycleWidth) return value + 1
clear()
leading.animate({ opacity: 0 })
return 0
}),
MARQUEE_INTERVAL,
)
}
const enter = (sessionID: string, title: string, width: number) => {
if (!marqueeOverflows(title, width)) {
+11
View File
@@ -76,6 +76,13 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
let history: SessionTabHistory = { entries: [], index: -1 }
// User-closed tabs eligible for reopening; in-memory like history, deleted sessions pruned.
let closedTabs: ClosedSessionTab[] = []
// Storage mutations apply against the on-disk draft under a file lock, so
// a registration queued by the route effect can land AFTER a removal that
// ran while the write was still in flight — resurrecting a tab that was
// just closed. Removing a tab marks it cancelled so any late-applying
// registration becomes a no-op; navigating to the session again clears
// the mark.
const cancelledTabs = new Set<string>()
const scrollAnchors = new Map<string, ScrollAnchor>()
const onFocus = () => setFocused(true)
@@ -152,6 +159,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
if (!enabled()) return
if (route.data.type !== "session" || route.data.sessionID === "dummy") return
const sessionID = root(route.data.sessionID)
cancelledTabs.delete(sessionID)
history = recordSessionTabHistory(history, sessionID)
const fallback = newTab() ? NEW_SESSION_TAB_TITLE : undefined
const tabs = openSessionTab(state().tabs, {
@@ -160,6 +168,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
})
if (tabs === state().tabs) return
update((draft) => {
if (cancelledTabs.has(sessionID)) return
draft.tabs = openSessionTab(draft.tabs, {
sessionID,
title: title(sessionID, draft.tabs.find((tab) => tab.sessionID === sessionID)?.title, fallback),
@@ -266,6 +275,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
function remove(sessionID: string, navigate: boolean) {
const target = root(sessionID)
cancelledTabs.add(target)
scrollAnchors.delete(target)
const closed = closeSessionTab(state().tabs, target)
const selected = navigate && current() === target
@@ -352,6 +362,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
closedTabs = result.stack
const tabs = result.tabs
if (!tabs || !result.sessionID) return
cancelledTabs.delete(result.sessionID)
update((draft) => {
draft.tabs = tabs
})
@@ -23,7 +23,7 @@ describe("session tab marquee", () => {
scope.dispose()
})
test("keeps the leading fade through a natural loop boundary", () => {
test("stops after one cycle", () => {
jest.useFakeTimers()
const scope = createRoot((dispose) => ({ marquee: createMarquee(() => false), dispose }))
@@ -32,7 +32,7 @@ describe("session tab marquee", () => {
expect(scope.marquee.active()).toBe("first")
expect(scope.marquee.offset()).toBe(0)
expect(scope.marquee.leading()).toBe(1)
expect(scope.marquee.leading()).toBe(0)
scope.dispose()
})