mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-24 06:33:01 -04:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b98af3f9e | |||
| 7b349654e3 | |||
| 3d2652d7b9 | |||
| 1dea4b9391 | |||
| 15864304a5 | |||
| e312d261a8 | |||
| 2a83911c7e | |||
| 0eaa04718c | |||
| 2e5ec616d2 | |||
| b2551b4e5d | |||
| e81450809d | |||
| 2524e6be8b | |||
| b58f29a4ef | |||
| 8fec7e0e91 | |||
| 94f9d32040 | |||
| ea3e0dde19 | |||
| b731b11184 | |||
| 8676dcf705 | |||
| 2636797c65 | |||
| e673807e39 | |||
| 9a3a1732f1 | |||
| e03a147b71 | |||
| e461fdc2d0 |
@@ -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
@@ -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="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 } },
|
||||
})
|
||||
|
||||
|
||||
-29
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
-29
@@ -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}' },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
@@ -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" } })
|
||||
})
|
||||
|
||||
@@ -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)]])),
|
||||
)
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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, {})
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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
@@ -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 }]
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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\\")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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 },
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
})
|
||||
}),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
})
|
||||
|
||||
@@ -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 }))),
|
||||
)
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) }
|
||||
: {}),
|
||||
}
|
||||
|
||||
@@ -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}`))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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> {}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user