mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-27 20:16:17 +00:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
476affcf89 | ||
|
|
acb8e4f3dc | ||
|
|
0c77f6ed5b | ||
|
|
284b222489 | ||
|
|
705606face | ||
|
|
b738ef970d | ||
|
|
4112698e72 | ||
|
|
a609174969 | ||
|
|
d3694a5383 | ||
|
|
fafcea42e6 | ||
|
|
95c3c3f962 | ||
|
|
497a24c17d | ||
|
|
f1521000ec | ||
|
|
8a3e9890d2 | ||
|
|
2142de87dd | ||
|
|
367cf59612 | ||
|
|
2b8a3e7ee0 | ||
|
|
6dbb5f5008 | ||
|
|
8d1d1ee982 | ||
|
|
c33dc8808b | ||
|
|
5253603583 | ||
|
|
5fb3040505 | ||
|
|
df7dae3706 | ||
|
|
83ff4253f5 | ||
|
|
89cb5146d4 | ||
|
|
b0b5bf6749 | ||
|
|
ef9b9fb6bf | ||
|
|
c05d481ec6 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Propagate fatal Git ignore-check errors during index refresh so best-effort snapshot capture returns unavailable instead of treating the check as having no matches.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Rename the write, patch, and question tool formatting helpers from `toModelOutput` to `toModelContent` to match the result field they populate. Direct imports of these helpers must use the new name; generated content and declared machine output are unchanged.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/core": minor
|
||||
---
|
||||
|
||||
Rename ModelResolver.supported to ModelResolver.hasPackage. Consumers of the old export must update the name; the predicate remains Boolean(model.package), checking only whether a catalog model declares a provider package, not whether it can be loaded. Default-model selection behavior is unchanged.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Use parent directory names for SKILL.md IDs so individually downloaded skills do not overwrite each other.
|
||||
@@ -125,7 +125,7 @@
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/pty": "0.1.12",
|
||||
"@opencode-ai/pty": "0.1.13",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"@opencode-ai/tui": "workspace:*",
|
||||
@@ -364,7 +364,7 @@
|
||||
"@opencode-ai/ai": "workspace:*",
|
||||
"@opencode-ai/codemode": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/pty": "0.1.12",
|
||||
"@opencode-ai/pty": "0.1.13",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
@@ -2175,19 +2175,19 @@
|
||||
|
||||
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
|
||||
|
||||
"@opencode-ai/pty": ["@opencode-ai/pty@0.1.12", "", { "optionalDependencies": { "@opencode-ai/pty-darwin-arm64": "0.1.12", "@opencode-ai/pty-darwin-x64": "0.1.12", "@opencode-ai/pty-linux-arm64-gnu": "0.1.12", "@opencode-ai/pty-linux-arm64-musl": "0.1.12", "@opencode-ai/pty-linux-x64-gnu": "0.1.12", "@opencode-ai/pty-linux-x64-musl": "0.1.12" }, "bin": { "opencode-pty": "bin/opencode-pty.js" } }, "sha512-dl4FyJUhTXThsWYY8txG/8/nwN7dE0M5Sic9r4L9f2pvtJnbR5zrCrPoiPIBIxZle1wVks1dhz4z/CfqLf5sCg=="],
|
||||
"@opencode-ai/pty": ["@opencode-ai/pty@0.1.13", "", { "optionalDependencies": { "@opencode-ai/pty-darwin-arm64": "0.1.13", "@opencode-ai/pty-darwin-x64": "0.1.13", "@opencode-ai/pty-linux-arm64-gnu": "0.1.13", "@opencode-ai/pty-linux-arm64-musl": "0.1.13", "@opencode-ai/pty-linux-x64-gnu": "0.1.13", "@opencode-ai/pty-linux-x64-musl": "0.1.13" }, "bin": { "opencode-pty": "bin/opencode-pty.js" } }, "sha512-WPCN8h8HaZhhUcrMG0zu+4D9vco0EZiEg/gCF1K3JPRN6UsHMiXq1HVIy5IlyfcoyjfViRmQmXYE4AuU3laBjA=="],
|
||||
|
||||
"@opencode-ai/pty-darwin-arm64": ["@opencode-ai/pty-darwin-arm64@0.1.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tMvoriq3VegVlj1uEglc6qE0M7VXy61nyf9Si7tTO7xa8JiyxuFJSXOZ1pGeErDu+pe24hvTyVOR+gkdew8w9g=="],
|
||||
"@opencode-ai/pty-darwin-arm64": ["@opencode-ai/pty-darwin-arm64@0.1.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-fVtQZqVLBuJx/aB+5ojfmQifS1KMc9gxlxpFQ6bxEFU8tn8xHQTiFPaNroZgOtaw7I4ceGyx/eXieK1wp68yAA=="],
|
||||
|
||||
"@opencode-ai/pty-darwin-x64": ["@opencode-ai/pty-darwin-x64@0.1.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-Sn5vMLL5giHOhx7J5H6zwDp4YjjXorY+QV0IEYY+SCT4wQfRBliokIyj23pRl6P2RK3u9bDLXJHDNMfDVZ2Rxg=="],
|
||||
"@opencode-ai/pty-darwin-x64": ["@opencode-ai/pty-darwin-x64@0.1.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-b/tAEm0hCMXraPM9cxR8Rg7X1UBZInRTaxWAS4Ht9eH1nWj1rANOLvHWiWX/vVh5TB0Ubg8bWPu4B0nZkEHROQ=="],
|
||||
|
||||
"@opencode-ai/pty-linux-arm64-gnu": ["@opencode-ai/pty-linux-arm64-gnu@0.1.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-HbnlKZy052l7G527wK0+05EXaUpZ4ykVAmNBEzqWCoi4TeQj2+Nr9kJ9trx9o1KrVcT4Ki58CCvN5QOls6Z0yQ=="],
|
||||
"@opencode-ai/pty-linux-arm64-gnu": ["@opencode-ai/pty-linux-arm64-gnu@0.1.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-I124aSYBBjpGZnYExHfIajkvVK1FiK+//OJBGdqqFp5pas2Oruq4O8tv+pMoxomZIYh2ce/QhOOYLHRwXsthTg=="],
|
||||
|
||||
"@opencode-ai/pty-linux-arm64-musl": ["@opencode-ai/pty-linux-arm64-musl@0.1.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-2nTN7ggu1h9XgjNcoQMYjP5sirfYnAskpdFCOqjokLqhytX/IMMmkRTQs+foaEaPz0dAIQD3DQplR2jZIgxp1w=="],
|
||||
"@opencode-ai/pty-linux-arm64-musl": ["@opencode-ai/pty-linux-arm64-musl@0.1.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-feWsfKpaDytGJzutoK43GqQwVghG2vHZt6BE/ydPZNuqIrySQ/6JfliUAMwn5BWs/Ky7ouSwKHCyAVeukusSvg=="],
|
||||
|
||||
"@opencode-ai/pty-linux-x64-gnu": ["@opencode-ai/pty-linux-x64-gnu@0.1.12", "", { "os": "linux", "cpu": "x64" }, "sha512-FnD5ndnObTQKAoaVvxLKi5W+r3/+dsaMsobz6uK0B9hlmffXxY5CQ6HQyUU/h3aIKLWXxho5XYkA2b9yrp8/gA=="],
|
||||
"@opencode-ai/pty-linux-x64-gnu": ["@opencode-ai/pty-linux-x64-gnu@0.1.13", "", { "os": "linux", "cpu": "x64" }, "sha512-jliNgsevGuxfIeX7eyzjHhrJkF8uEUPnDLbF2v16uv69FhEHrraf7jyWkxazMP6rNvn2CGtwMMc4BXPS5pzjhg=="],
|
||||
|
||||
"@opencode-ai/pty-linux-x64-musl": ["@opencode-ai/pty-linux-x64-musl@0.1.12", "", { "os": "linux", "cpu": "x64" }, "sha512-prkrNu6uvjqoffxdGiDHSU5C0Y+kCSfv+lslu7dfRPgPKenVELNpRTAbOduyrWPac2vGt8j5NM61icJyodbJmA=="],
|
||||
"@opencode-ai/pty-linux-x64-musl": ["@opencode-ai/pty-linux-x64-musl@0.1.13", "", { "os": "linux", "cpu": "x64" }, "sha512-rXDpidW66gz2b2M/NbUN8ZKmAxaJcASnuHATeXevlrFdiPUv8uJwvkRd6Pla1fp01Q65MkBmgRa7Q9c+H1PlzA=="],
|
||||
|
||||
"@opencode-ai/schema": ["@opencode-ai/schema@workspace:packages/schema"],
|
||||
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-iYdVrLtyKmjlyypisF9SqzgyriWT90kSCh3crxw9AKU=",
|
||||
"aarch64-linux": "sha256-BV2t4w5ujArbtSC/Qfm3gLzevQW9A6hMgOyPVp94g/o=",
|
||||
"aarch64-darwin": "sha256-EwMq7zaxzzcsmH0Pjqu4ftGdcM8Lna8mvHgKzRcVI8g=",
|
||||
"x86_64-darwin": "sha256-PokzxlkQy6JvHADF2ZMIIDI1u9ZjSNNedpmR9gvHS5c="
|
||||
"x86_64-linux": "sha256-agxF1Ab8P84tijHvw7jaG+9UZm/dJZhHw8CGNlQQqYc=",
|
||||
"aarch64-linux": "sha256-Kg6CkhoEEsb+ZOGPN/rnNoirLdGSyQl6HU/Fy8t9Mqs=",
|
||||
"aarch64-darwin": "sha256-SnxBpmcfPL2p3OYmSuG5Y/aFW8PeEOLhfQrxAs/53fs=",
|
||||
"x86_64-darwin": "sha256-Dnxl1EcfiE7/rlHmPkko0BKc6hHVk6rQ/2h/PWuLfNo="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,14 @@ Per-type constructors live on the type, not as top-level re-exports. Use `Messag
|
||||
- Use `testEffect(...)` from `test/lib/effect.ts` for tests requiring Effect layers.
|
||||
- Keep provider tests fixture-first. Live provider calls must stay behind `RECORD=true` and required API-key checks.
|
||||
|
||||
## Errors
|
||||
|
||||
- `AIError` wraps a union of tagged reason errors. It stores only `reason`, derives `message` from the reason, and exposes the reason as its `cause`.
|
||||
- Each reason owns its readable `message`, category-specific fields, and optional `body`, `http`, and underlying exception in `cause`.
|
||||
- `reason.body` is the sole original-response or triggering-event payload field. Preserve original text before schema decoding removes fields; do not replace the complete event with only its nested error.
|
||||
- `reason.http` describes an observed HTTP response with required `url`, `status`, and response `headers`. Do not invent status codes or derive a separate request ID from headers.
|
||||
- Reclassification and transport recovery must preserve the reason's body, HTTP context, and underlying cause. Error `message` and `cause` are non-enumerable: copy them explicitly when constructing an enriched reason with its constructor or `AIErrorReason.make`.
|
||||
|
||||
## Architecture
|
||||
|
||||
This package is an Effect Schema-first LLM core. The Schema classes in `src/schema/` are the canonical runtime data model. Convenience functions in `src/llm.ts` are thin constructors that return those same Schema class instances; they should improve callsites without creating a second model.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import {
|
||||
HttpOptions,
|
||||
InvalidRequestReason,
|
||||
InvalidRequestError,
|
||||
AIError,
|
||||
ModelID,
|
||||
ProviderID,
|
||||
@@ -158,9 +158,10 @@ export function generate(input: ImageRequest | ImageRequestInput) {
|
||||
try: () => (input instanceof ImageRequest ? input : request(input)),
|
||||
catch: (error) =>
|
||||
new AIError({
|
||||
module: "Image",
|
||||
method: "generate",
|
||||
reason: new InvalidRequestReason({ message: error instanceof Error ? error.message : String(error) }),
|
||||
reason: new InvalidRequestError({
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
cause: error,
|
||||
}),
|
||||
}),
|
||||
}).pipe(Effect.flatMap((request) => ImageClient.generate(request as unknown as ImageRequestFor<ImageOptions>)))
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { LLMClient, Service } from "./route/client.js"
|
||||
import {
|
||||
GenerationOptions,
|
||||
HttpOptions,
|
||||
InvalidProviderOutputReason,
|
||||
InvalidProviderOutputError,
|
||||
AIError,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
@@ -116,9 +116,7 @@ const runGenerateObject = Effect.fn("LLM.generateObject")(function* (
|
||||
)
|
||||
if (!call || !LLMEvent.is.toolCall(call))
|
||||
return yield* new AIError({
|
||||
module: "LLM",
|
||||
method: "generateObject",
|
||||
reason: new InvalidProviderOutputReason({
|
||||
reason: new InvalidProviderOutputError({
|
||||
message: `generateObject: model did not call the forced \`${GENERATE_OBJECT_TOOL_NAME}\` tool`,
|
||||
}),
|
||||
})
|
||||
@@ -126,10 +124,9 @@ const runGenerateObject = Effect.fn("LLM.generateObject")(function* (
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new AIError({
|
||||
module: "LLM",
|
||||
method: "generateObject",
|
||||
reason: new InvalidProviderOutputReason({
|
||||
reason: new InvalidProviderOutputError({
|
||||
message: `generateObject: tool input failed schema decode: ${error.message}`,
|
||||
cause: error,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -1387,14 +1387,15 @@ const providerErrorMessage = (event: AnthropicEvent): string => {
|
||||
return message || type || "Anthropic Messages stream error"
|
||||
}
|
||||
|
||||
const onError = (event: AnthropicEvent) =>
|
||||
Effect.fail(
|
||||
const onError = (event: AnthropicEvent) => {
|
||||
const message = providerErrorMessage(event)
|
||||
const body = ProviderShared.encodeJson(event)
|
||||
return Effect.fail(
|
||||
new AIError({
|
||||
module: ADAPTER,
|
||||
method: "stream",
|
||||
reason: classifyProviderFailure({ message: providerErrorMessage(event), code: event.error?.type }),
|
||||
reason: classifyProviderFailure({ message, rawBody: body }),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const isKnownStreamBlockType = (type: string) =>
|
||||
type === "text" ||
|
||||
|
||||
@@ -645,15 +645,13 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
}
|
||||
|
||||
if (event.exception) {
|
||||
const message =
|
||||
event.exception.details.message ?? event.exception.details.originalMessage ?? "Bedrock Converse stream error"
|
||||
const body = ProviderShared.encodeJson(event)
|
||||
return yield* new AIError({
|
||||
module: ADAPTER,
|
||||
method: "stream",
|
||||
reason: classifyProviderFailure({
|
||||
message:
|
||||
event.exception.details.message ??
|
||||
event.exception.details.originalMessage ??
|
||||
"Bedrock Converse stream error",
|
||||
code: event.exception.type,
|
||||
message,
|
||||
rawBody: body,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { EventStreamCodec } from "@smithy/eventstream-codec"
|
||||
import { fromUtf8, toUtf8 } from "@smithy/util-utf8"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Effect, Encoding, Stream } from "effect"
|
||||
import { AIError, AIErrorReason } from "../schema/index.js"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import { ProviderShared } from "./shared.js"
|
||||
|
||||
@@ -49,10 +50,14 @@ const consumeFrames = (route: string) => (state: FrameBufferState, chunk: Uint8A
|
||||
`Failed to decode Bedrock Converse event-stream frame: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
Encoding.encodeBase64(view.subarray(0, totalLength)),
|
||||
error,
|
||||
),
|
||||
})
|
||||
cursor = { buffer: cursor.buffer, offset: cursor.offset + totalLength }
|
||||
|
||||
const payload = utf8.decode(decoded.body)
|
||||
const body = ProviderShared.encodeJson({ headers: decoded.headers, body: payload })
|
||||
const messageType = decoded.headers[":message-type"]?.value
|
||||
if (messageType === "error") {
|
||||
const code = decoded.headers[":error-code"]?.value
|
||||
@@ -61,6 +66,7 @@ const consumeFrames = (route: string) => (state: FrameBufferState, chunk: Uint8A
|
||||
route,
|
||||
[code, message].filter((value): value is string => typeof value === "string").join(": ") ||
|
||||
"Bedrock Converse event-stream error",
|
||||
body,
|
||||
)
|
||||
}
|
||||
const eventType =
|
||||
@@ -70,7 +76,6 @@ const consumeFrames = (route: string) => (state: FrameBufferState, chunk: Uint8A
|
||||
? decoded.headers[":exception-type"]?.value
|
||||
: undefined
|
||||
if (typeof eventType !== "string") continue
|
||||
const payload = utf8.decode(decoded.body)
|
||||
if (!payload) continue
|
||||
// The AWS event stream pads short payloads with a `p` field. Drop it
|
||||
// before handing the object to the chunk schema. JSON decode goes
|
||||
@@ -80,11 +85,21 @@ const consumeFrames = (route: string) => (state: FrameBufferState, chunk: Uint8A
|
||||
route,
|
||||
payload,
|
||||
"Failed to parse Bedrock Converse event-stream payload",
|
||||
).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new AIError({
|
||||
reason: AIErrorReason.make({ ...error.reason, message: error.message, cause: error.reason.cause, body }),
|
||||
}),
|
||||
),
|
||||
)) as Record<string, unknown>
|
||||
delete parsed.p
|
||||
out.push(
|
||||
messageType === "exception" ? { exception: { type: eventType, details: parsed } } : { [eventType]: parsed },
|
||||
)
|
||||
out.push({
|
||||
...(messageType === "exception"
|
||||
? { exception: { type: eventType, details: parsed } }
|
||||
: { [eventType]: parsed }),
|
||||
rawBody: body,
|
||||
})
|
||||
}
|
||||
return [cursor, out] as const
|
||||
})
|
||||
@@ -97,6 +112,7 @@ const consumeFrames = (route: string) => (state: FrameBufferState, chunk: Uint8A
|
||||
*/
|
||||
export const framing = (route: string): Framing.Definition<object> => ({
|
||||
id: "aws-event-stream",
|
||||
body: (frame) => ("rawBody" in frame && typeof frame.rawBody === "string" ? frame.rawBody : undefined),
|
||||
frame: (bytes) => bytes.pipe(Stream.mapAccumEffect(() => initialFrameBuffer, consumeFrames(route))),
|
||||
})
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Endpoint } from "../route/endpoint.js"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import {
|
||||
AIError,
|
||||
LLMEvent,
|
||||
Usage,
|
||||
type FinishReason,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
} from "../schema/index.js"
|
||||
import { classifyProviderFailure } from "../provider-error.js"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { GeminiToolSchema } from "./utils/gemini-tool-schema.js"
|
||||
import { Lifecycle } from "./utils/lifecycle.js"
|
||||
@@ -221,6 +223,7 @@ const GeminiPromptFeedback = Schema.StructWithRest(
|
||||
type GeminiPromptFeedback = Schema.Schema.Type<typeof GeminiPromptFeedback>
|
||||
|
||||
const GeminiEvent = Schema.Struct({
|
||||
error: Schema.optional(Schema.Unknown),
|
||||
candidates: optionalNull(Schema.Array(GeminiCandidate)),
|
||||
promptFeedback: optionalNull(GeminiPromptFeedback),
|
||||
usageMetadata: optionalNull(GeminiUsage),
|
||||
@@ -598,6 +601,18 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||
}
|
||||
|
||||
const step = (state: ParserState, event: GeminiEvent) => {
|
||||
if (ProviderShared.isRecord(event.error) && typeof event.error.message === "string") {
|
||||
const body = ProviderShared.encodeJson(event)
|
||||
return Effect.fail(
|
||||
new AIError({
|
||||
reason: classifyProviderFailure({
|
||||
message: event.error.message,
|
||||
status: typeof event.error.code === "number" ? event.error.code : undefined,
|
||||
rawBody: body,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
const nextState = {
|
||||
...state,
|
||||
promptFeedback: event.promptFeedback ?? state.promptFeedback,
|
||||
|
||||
@@ -9,15 +9,7 @@ import {
|
||||
type ImageRoute,
|
||||
} from "../image.js"
|
||||
import { Auth, type Definition as AuthDefinition } from "../route/auth.js"
|
||||
import {
|
||||
InvalidProviderOutputReason,
|
||||
AIError,
|
||||
Usage,
|
||||
mergeHttpOptions,
|
||||
mergeJsonRecords,
|
||||
type HttpOptions,
|
||||
type ProviderMetadata,
|
||||
} from "../schema/index.js"
|
||||
import { AIError, Usage, mergeHttpOptions, mergeJsonRecords, type HttpOptions } from "../schema/index.js"
|
||||
import { ProviderShared } from "./shared.js"
|
||||
import { ImageInputs } from "./utils/image-input.js"
|
||||
|
||||
@@ -124,13 +116,6 @@ const nativeOptions = (options: GoogleImageOptions | undefined) => {
|
||||
)
|
||||
}
|
||||
|
||||
const invalidOutput = (message: string, providerMetadata?: ProviderMetadata) =>
|
||||
new AIError({
|
||||
module: ADAPTER,
|
||||
method: "generate",
|
||||
reason: new InvalidProviderOutputReason({ message, route: ADAPTER, providerMetadata }),
|
||||
})
|
||||
|
||||
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
|
||||
if (!query) return url
|
||||
const next = new URL(url)
|
||||
@@ -169,11 +154,9 @@ export const model = (input: ModelInput) => {
|
||||
HttpClientRequest.bodyText(text, "application/json"),
|
||||
),
|
||||
)
|
||||
const payload = yield* response.json.pipe(
|
||||
Effect.mapError(() => invalidOutput("Failed to read the Google Images response")),
|
||||
)
|
||||
const decoded = yield* Schema.decodeUnknownEffect(GoogleImageResponse)(payload).pipe(
|
||||
Effect.mapError(() => invalidOutput("Google Images returned an invalid response")),
|
||||
const output = yield* ProviderShared.imageResponse(ADAPTER, "Google Images", response)
|
||||
const decoded = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(GoogleImageResponse))(output.body).pipe(
|
||||
Effect.mapError((cause) => output.invalid("Google Images returned an invalid response", cause)),
|
||||
)
|
||||
const candidates = decoded.candidates ?? []
|
||||
const candidateMetadata = candidates.map((candidate, candidateIndex) => ({
|
||||
@@ -208,9 +191,10 @@ export const model = (input: ModelInput) => {
|
||||
)
|
||||
const images = yield* Effect.forEach(encoded, (item) =>
|
||||
Effect.fromResult(Encoding.decodeBase64(item.inlineData.data)).pipe(
|
||||
Effect.mapError(() =>
|
||||
invalidOutput(
|
||||
Effect.mapError((cause) =>
|
||||
output.invalid(
|
||||
`Google Images candidate ${item.candidateIndex} part ${item.partIndex} contains invalid base64 data`,
|
||||
cause,
|
||||
),
|
||||
),
|
||||
Effect.map(
|
||||
@@ -237,16 +221,10 @@ export const model = (input: ModelInput) => {
|
||||
const finishReasons = candidates.flatMap((candidate) =>
|
||||
candidate.finishReason === undefined ? [] : [candidate.finishReason],
|
||||
)
|
||||
return yield* invalidOutput(
|
||||
return yield* output.invalid(
|
||||
`Google Images returned no final images${
|
||||
finishReasons.length === 0 ? "" : ` (finish reasons: ${finishReasons.join(", ")})`
|
||||
}; inspect reason.providerMetadata.google for prompt feedback and candidate details`,
|
||||
{
|
||||
google: {
|
||||
promptFeedback: decoded.promptFeedback,
|
||||
candidates: candidateMetadata,
|
||||
},
|
||||
},
|
||||
}; inspect body for prompt feedback and candidate details`,
|
||||
)
|
||||
}
|
||||
const usage = decoded.usageMetadata
|
||||
@@ -290,12 +268,11 @@ const googleImagePart = (image: ImageInput): Effect.Effect<Record<string, unknow
|
||||
return Effect.succeed({ inlineData: { mimeType: image.mediaType, data: Encoding.encodeBase64(image.data) } })
|
||||
if (image.type === "file-uri") return Effect.succeed({ fileData: { mimeType: image.mediaType, fileUri: image.uri } })
|
||||
if (image.type === "url")
|
||||
return ImageInputs.decodeDataUrl(image.url, ADAPTER).pipe(
|
||||
return ImageInputs.decodeDataUrl(image.url).pipe(
|
||||
Effect.flatMap((decoded) => {
|
||||
if (decoded === undefined)
|
||||
return Effect.fail(
|
||||
ImageInputs.invalid(
|
||||
ADAPTER,
|
||||
"Google generateContent does not fetch public image URLs; use bytes, a data URL, or a Gemini file URI",
|
||||
),
|
||||
)
|
||||
@@ -305,7 +282,7 @@ const googleImagePart = (image: ImageInput): Effect.Effect<Record<string, unknow
|
||||
}),
|
||||
)
|
||||
return Effect.fail(
|
||||
ImageInputs.invalid(ADAPTER, "Google generateContent requires Gemini file URIs rather than provider file IDs"),
|
||||
ImageInputs.invalid("Google generateContent requires Gemini file URIs rather than provider file IDs"),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import type { HttpContext } from "../schema/index.js"
|
||||
import {
|
||||
HttpTransport,
|
||||
WebSocketTransport,
|
||||
@@ -60,8 +61,8 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
|
||||
observe: (_create, frame) =>
|
||||
Effect.gen(function* () {
|
||||
const event = yield* decodeEvent(frame).pipe(
|
||||
Effect.mapError(() =>
|
||||
ProviderShared.eventError(options.id, `Invalid ${options.name} WebSocket event`, frame),
|
||||
Effect.mapError((cause) =>
|
||||
ProviderShared.eventError(options.id, `Invalid ${options.name} WebSocket event`, frame, cause),
|
||||
),
|
||||
)
|
||||
if (terminal)
|
||||
@@ -73,13 +74,13 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
|
||||
if (event.type === "error") {
|
||||
terminal = true
|
||||
yield* OpenResponses.decodeKnownErrorEvent(event).pipe(
|
||||
Effect.mapError(() =>
|
||||
ProviderShared.eventError(options.id, `${options.name} returned a malformed error event`, frame),
|
||||
Effect.mapError((cause) =>
|
||||
ProviderShared.eventError(options.id, `${options.name} returned a malformed error event`, frame, cause),
|
||||
),
|
||||
)
|
||||
return {
|
||||
type: "provider-failure",
|
||||
error: OpenResponses.providerFailure(options.id, event, `${options.name} stream error`),
|
||||
error: OpenResponses.providerFailure(event, `${options.name} stream error`, frame),
|
||||
}
|
||||
}
|
||||
if (event.type === "response.failed") {
|
||||
@@ -92,7 +93,7 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
|
||||
)
|
||||
return {
|
||||
type: "provider-failure",
|
||||
error: OpenResponses.providerFailure(options.id, event, `${options.name} response failed`),
|
||||
error: OpenResponses.providerFailure(event, `${options.name} response failed`, frame),
|
||||
}
|
||||
}
|
||||
if (event.type === "response.created") {
|
||||
@@ -175,23 +176,37 @@ export const transport = <Body>(options: Options): Transport<Body, Prepared, str
|
||||
channel,
|
||||
}
|
||||
}),
|
||||
execute: (prepared, request, runtime, executeOptions) => {
|
||||
if (!executeOptions?.webSocket || !prepared.channel) return http.execute(prepared.http, request, runtime)
|
||||
const exchange: WebSocketChannelExchange = {
|
||||
id: request.id ?? "request",
|
||||
connect: {
|
||||
url: prepared.channel.url,
|
||||
headers: prepared.channel.headers,
|
||||
rotateAfterMs: prepared.channel.rotateAfterMs,
|
||||
},
|
||||
fallback: () =>
|
||||
Stream.unwrap(
|
||||
http.execute(prepared.http, request, runtime).pipe(Effect.map((execution) => execution.frames)),
|
||||
),
|
||||
driver: prepared.channel.driver,
|
||||
}
|
||||
return executeOptions.webSocket.execute(exchange)
|
||||
},
|
||||
execute: (prepared, request, runtime, executeOptions) =>
|
||||
Effect.gen(function* () {
|
||||
if (!executeOptions?.webSocket || !prepared.channel) return yield* http.execute(prepared.http, request, runtime)
|
||||
let fallbackHttp: HttpContext | undefined
|
||||
const exchange: WebSocketChannelExchange = {
|
||||
id: request.id ?? "request",
|
||||
connect: {
|
||||
url: prepared.channel.url,
|
||||
headers: prepared.channel.headers,
|
||||
rotateAfterMs: prepared.channel.rotateAfterMs,
|
||||
},
|
||||
fallback: () =>
|
||||
Stream.unwrap(
|
||||
http.execute(prepared.http, request, runtime).pipe(
|
||||
Effect.map((execution) => {
|
||||
fallbackHttp = execution.http
|
||||
return execution.frames
|
||||
}),
|
||||
),
|
||||
),
|
||||
driver: prepared.channel.driver,
|
||||
}
|
||||
const execution = yield* executeOptions.webSocket.execute(exchange)
|
||||
return {
|
||||
frames: execution.frames,
|
||||
complete: execution.complete,
|
||||
get http() {
|
||||
return fallbackHttp ?? execution.http
|
||||
},
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AIError, TransportReason } from "../schema/index.js"
|
||||
import { AIError, TransportError } from "../schema/index.js"
|
||||
import type { ChannelCheckpoint, ChannelObservation, WebSocketChannelDriver } from "../route/transport/index.js"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import * as ProviderShared from "./shared.js"
|
||||
@@ -99,17 +99,17 @@ const incremental = (
|
||||
const code = (event: OpenResponses.Event) => event.code || event.error?.code || event.response?.error?.code || undefined
|
||||
|
||||
const rejected = (
|
||||
input: DriverInput,
|
||||
observation: Extract<ChannelObservation, { readonly type: "provider-failure" }>,
|
||||
recovery: "retry-full" | "rotate-and-retry-full",
|
||||
): ChannelObservation => ({
|
||||
type: "rejected",
|
||||
recovery,
|
||||
error: new AIError({
|
||||
module: input.id,
|
||||
method: "stream",
|
||||
reason: new TransportReason({
|
||||
reason: new TransportError({
|
||||
message: observation.error.message,
|
||||
body: observation.error.reason.body,
|
||||
http: observation.error.reason.http,
|
||||
cause: observation.error.reason.cause,
|
||||
transport: "websocket",
|
||||
operation: "read",
|
||||
phase: "receive",
|
||||
@@ -137,15 +137,16 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
|
||||
observe: (create, frame) =>
|
||||
Effect.gen(function* () {
|
||||
const event = yield* decodeEvent(frame).pipe(
|
||||
Effect.mapError(() => ProviderShared.eventError(input.id, `Invalid ${input.name} WebSocket event`, frame)),
|
||||
Effect.mapError((cause) =>
|
||||
ProviderShared.eventError(input.id, `Invalid ${input.name} WebSocket event`, frame, cause),
|
||||
),
|
||||
)
|
||||
const observation = yield* input.base.observe(create, frame)
|
||||
if (event.type === "response.output_item.done" && event.item) output.push(event.item)
|
||||
if (observation.type === "provider-failure") {
|
||||
const rejection = code(event)
|
||||
if (rejection === "previous_response_not_found") return rejected(input, observation, "retry-full")
|
||||
if (rejection === "websocket_connection_limit_reached")
|
||||
return rejected(input, observation, "rotate-and-retry-full")
|
||||
if (rejection === "previous_response_not_found") return rejected(observation, "retry-full")
|
||||
if (rejection === "websocket_connection_limit_reached") return rejected(observation, "rotate-and-retry-full")
|
||||
}
|
||||
if (observation.type !== "completed") return observation
|
||||
const responseID = event.response?.id
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Protocol } from "../route/protocol.js"
|
||||
import {
|
||||
AIError,
|
||||
LLMEvent,
|
||||
ProviderInternalReason,
|
||||
ProviderInternalError,
|
||||
Usage,
|
||||
type FinishReason,
|
||||
type JsonSchema,
|
||||
@@ -1188,11 +1188,8 @@ const providerErrorMessage = (event: Event, nested: OpenResponsesErrorPayload |
|
||||
return message || code
|
||||
}
|
||||
|
||||
export const providerFailure = (id: string, event: Event, fallback: string) => {
|
||||
export const providerFailure = (event: Event, fallback: string, body = ProviderShared.encodeJson(event)) => {
|
||||
const nested = event.error ?? event.response?.error ?? undefined
|
||||
const code = event.code || nested?.code || undefined
|
||||
// Keep the full raw payload on the error even when the message is a summary.
|
||||
const body = JSON.stringify(nested ?? event) ?? ""
|
||||
const summary = providerErrorMessage(event, nested)
|
||||
const message = summary ?? (body === "{}" ? fallback : body)
|
||||
const status =
|
||||
@@ -1207,18 +1204,11 @@ export const providerFailure = (id: string, event: Event, fallback: string) => {
|
||||
event.response === undefined &&
|
||||
summary === undefined &&
|
||||
status === undefined
|
||||
? new ProviderInternalReason({ message })
|
||||
: classifyProviderFailure({ message, code, status, rawBody: body })
|
||||
return new AIError({
|
||||
module: id,
|
||||
method: "stream",
|
||||
body,
|
||||
reason,
|
||||
})
|
||||
? new ProviderInternalError({ message, body })
|
||||
: classifyProviderFailure({ message, status, rawBody: body })
|
||||
return new AIError({ reason })
|
||||
}
|
||||
|
||||
const providerError = (state: ParserState, event: Event, fallback: string) => providerFailure(state.id, event, fallback)
|
||||
|
||||
export const step = (state: ParserState, input: Event) => {
|
||||
// The OpenAPI requires string IDs but imposes no minLength; empty is not missing.
|
||||
const event =
|
||||
@@ -1286,11 +1276,18 @@ export const step = (state: ParserState, input: Event) => {
|
||||
return onOutputItemDone(state, event)
|
||||
}
|
||||
if (event.type === "response.completed" || event.type === "response.incomplete") return onResponseFinish(state, event)
|
||||
if (event.type === "response.failed") return providerError(state, event, `${state.name} response failed`)
|
||||
if (event.type === "response.failed") return providerFailure(event, `${state.name} response failed`)
|
||||
if (event.type === "error")
|
||||
return decodeKnownErrorEvent(event).pipe(
|
||||
Effect.mapError(() => ProviderShared.eventError(state.id, `${state.name} returned a malformed error event`)),
|
||||
Effect.flatMap(() => providerError(state, event, `${state.name} stream error`)),
|
||||
Effect.mapError((cause) =>
|
||||
ProviderShared.eventError(
|
||||
state.id,
|
||||
`${state.name} returned a malformed error event`,
|
||||
ProviderShared.encodeJson(event),
|
||||
cause,
|
||||
),
|
||||
),
|
||||
Effect.flatMap(() => providerFailure(event, `${state.name} stream error`)),
|
||||
)
|
||||
return Effect.succeed<StepResult>([state, NO_EVENTS])
|
||||
}
|
||||
|
||||
@@ -7,10 +7,11 @@ import { HttpTransport } from "../route/transport/index.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import {
|
||||
AIError,
|
||||
InvalidProviderOutputReason,
|
||||
AIErrorReason,
|
||||
InvalidProviderOutputError,
|
||||
LLMEvent,
|
||||
ProviderInternalReason,
|
||||
UnknownProviderReason,
|
||||
ProviderInternalError,
|
||||
UnknownProviderError,
|
||||
Usage,
|
||||
type FinishReason,
|
||||
type FinishReasonDetails,
|
||||
@@ -784,26 +785,22 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
|
||||
// Streaming parsers are small state machines: every event returns a new state
|
||||
// plus the common `LLMEvent`s produced by that event. Tool calls are accumulated
|
||||
// because OpenAI streams JSON arguments across multiple deltas.
|
||||
const finishReasonError = (event: OpenAIChatEvent, reason: AIError["reason"]) =>
|
||||
new AIError({
|
||||
module: ADAPTER,
|
||||
method: "stream",
|
||||
body: ProviderShared.encodeJson(event),
|
||||
reason,
|
||||
})
|
||||
|
||||
const mapFinishReason = Effect.fn("OpenAIChat.mapFinishReason")(function* (event: OpenAIChatEvent, reason: string) {
|
||||
switch (reason) {
|
||||
case "error":
|
||||
return yield* finishReasonError(
|
||||
event,
|
||||
new UnknownProviderReason({ message: "Provider reported an error (finish_reason: error)" }),
|
||||
)
|
||||
return yield* new AIError({
|
||||
reason: new UnknownProviderError({
|
||||
message: "Provider reported an error (finish_reason: error)",
|
||||
body: ProviderShared.encodeJson(event),
|
||||
}),
|
||||
})
|
||||
case "network_error":
|
||||
return yield* finishReasonError(
|
||||
event,
|
||||
new ProviderInternalReason({ message: "Provider reported a network error (finish_reason: network_error)" }),
|
||||
)
|
||||
return yield* new AIError({
|
||||
reason: new ProviderInternalError({
|
||||
message: "Provider reported a network error (finish_reason: network_error)",
|
||||
body: ProviderShared.encodeJson(event),
|
||||
}),
|
||||
})
|
||||
case "stop":
|
||||
case "end":
|
||||
return "stop" as const
|
||||
@@ -936,12 +933,8 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
if (event.error) {
|
||||
const body = ProviderShared.encodeJson(event)
|
||||
return yield* new AIError({
|
||||
module: ADAPTER,
|
||||
method: "stream",
|
||||
body,
|
||||
reason: classifyProviderFailure({
|
||||
message: event.error.message,
|
||||
code: event.error.code === undefined || event.error.code === null ? undefined : String(event.error.code),
|
||||
status: typeof event.error.code === "number" ? event.error.code : undefined,
|
||||
rawBody: body,
|
||||
}),
|
||||
@@ -1066,7 +1059,14 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
"OpenAI Chat tool call delta is missing id or name",
|
||||
)
|
||||
if (ToolStream.isError(result))
|
||||
return yield* ProviderShared.eventError(ADAPTER, result.reason.message, ProviderShared.encodeJson(event))
|
||||
return yield* new AIError({
|
||||
reason: AIErrorReason.make({
|
||||
...result.reason,
|
||||
message: result.message,
|
||||
cause: result.reason.cause,
|
||||
body: ProviderShared.encodeJson(event),
|
||||
}),
|
||||
})
|
||||
tools = result.tools
|
||||
if (result.events.length) lifecycle = Lifecycle.stepStart(lifecycle, events)
|
||||
events.push(...result.events)
|
||||
@@ -1110,11 +1110,9 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
const finishEvents = Effect.fn("OpenAIChat.finishEvents")(function* (state: ParserState) {
|
||||
if (state.finishReason === undefined && state.requireFinishReason)
|
||||
return yield* new AIError({
|
||||
module: ADAPTER,
|
||||
method: "stream",
|
||||
reason: new InvalidProviderOutputReason({
|
||||
classification: "incomplete-stream",
|
||||
reason: new InvalidProviderOutputError({
|
||||
message: "OpenAI Chat stream ended without finish_reason",
|
||||
classification: "incomplete-stream",
|
||||
route: ADAPTER,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -9,14 +9,7 @@ import {
|
||||
type ImageRoute,
|
||||
} from "../image.js"
|
||||
import { Auth, type Definition as AuthDefinition } from "../route/auth.js"
|
||||
import {
|
||||
InvalidProviderOutputReason,
|
||||
AIError,
|
||||
Usage,
|
||||
mergeHttpOptions,
|
||||
mergeJsonRecords,
|
||||
type HttpOptions,
|
||||
} from "../schema/index.js"
|
||||
import { Usage, mergeHttpOptions, mergeJsonRecords, type HttpOptions } from "../schema/index.js"
|
||||
import { ProviderShared } from "./shared.js"
|
||||
import { ImageInputs } from "./utils/image-input.js"
|
||||
import { OpenAIImage } from "./utils/openai-image.js"
|
||||
@@ -84,13 +77,6 @@ const nativeOptions = (options: OpenAIImageOptions | undefined) => {
|
||||
}
|
||||
}
|
||||
|
||||
const invalidOutput = (message: string) =>
|
||||
new AIError({
|
||||
module: ADAPTER,
|
||||
method: "generate",
|
||||
reason: new InvalidProviderOutputReason({ message, route: ADAPTER }),
|
||||
})
|
||||
|
||||
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
|
||||
if (!query) return url
|
||||
const next = new URL(url)
|
||||
@@ -104,12 +90,12 @@ export const model = (input: ModelInput) => {
|
||||
generate: Effect.fn("OpenAIImages.generate")(function* (request: ImageRequestFor<OpenAIImageOptions>, execute) {
|
||||
const mask = request.options?.mask
|
||||
if (mask !== undefined && (request.images?.length ?? 0) === 0)
|
||||
return yield* ImageInputs.invalid(ADAPTER, "An OpenAI image mask requires at least one input image")
|
||||
return yield* ImageInputs.invalid("An OpenAI image mask requires at least one input image")
|
||||
const http = mergeHttpOptions(request.model.http, request.http)
|
||||
const sourceImages = request.images ?? []
|
||||
const multipartImages = yield* Effect.forEach(sourceImages, (image) => {
|
||||
if (image.type === "bytes") return Effect.succeed({ data: image.data, mediaType: image.mediaType })
|
||||
if (image.type === "url") return ImageInputs.decodeDataUrl(image.url, ADAPTER)
|
||||
if (image.type === "url") return ImageInputs.decodeDataUrl(image.url)
|
||||
return Effect.undefined
|
||||
})
|
||||
const multipartMask =
|
||||
@@ -118,7 +104,7 @@ export const model = (input: ModelInput) => {
|
||||
: mask.type === "bytes"
|
||||
? { data: mask.data, mediaType: mask.mediaType }
|
||||
: mask.type === "url"
|
||||
? yield* ImageInputs.decodeDataUrl(mask.url, ADAPTER)
|
||||
? yield* ImageInputs.decodeDataUrl(mask.url)
|
||||
: undefined
|
||||
const useMultipart =
|
||||
sourceImages.length > 0 &&
|
||||
@@ -161,7 +147,7 @@ export const model = (input: ModelInput) => {
|
||||
return undefined
|
||||
})
|
||||
if (references.some((image) => image === undefined))
|
||||
return yield* ImageInputs.invalid(ADAPTER, "OpenAI Images accepts image URLs, data URLs, bytes, and file IDs")
|
||||
return yield* ImageInputs.invalid("OpenAI Images accepts image URLs, data URLs, bytes, and file IDs")
|
||||
const maskReference =
|
||||
mask === undefined
|
||||
? undefined
|
||||
@@ -173,7 +159,7 @@ export const model = (input: ModelInput) => {
|
||||
? { file_id: mask.id }
|
||||
: undefined
|
||||
if (mask !== undefined && maskReference === undefined)
|
||||
return yield* ImageInputs.invalid(ADAPTER, "OpenAI Images accepts masks as URLs, data URLs, bytes, or file IDs")
|
||||
return yield* ImageInputs.invalid("OpenAI Images accepts masks as URLs, data URLs, bytes, or file IDs")
|
||||
const requestBody = mergeJsonRecords(
|
||||
{
|
||||
model: request.model.id,
|
||||
@@ -209,11 +195,9 @@ const parseResponse = Effect.fn("OpenAIImages.parseResponse")(function* (
|
||||
options: OpenAIImageOptions | undefined,
|
||||
overlay: Record<string, unknown> | undefined,
|
||||
) {
|
||||
const payload = yield* response.json.pipe(
|
||||
Effect.mapError(() => invalidOutput("Failed to read the OpenAI Images response")),
|
||||
)
|
||||
const decoded = yield* Schema.decodeUnknownEffect(OpenAIImageResponse)(payload).pipe(
|
||||
Effect.mapError(() => invalidOutput("OpenAI Images returned an invalid response")),
|
||||
const output = yield* ProviderShared.imageResponse(ADAPTER, "OpenAI Images", response)
|
||||
const decoded = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(OpenAIImageResponse))(output.body).pipe(
|
||||
Effect.mapError((cause) => output.invalid("OpenAI Images returned an invalid response", cause)),
|
||||
)
|
||||
const requestBody = mergeJsonRecords(nativeOptions(options), overlay)
|
||||
const format =
|
||||
@@ -221,7 +205,7 @@ const parseResponse = Effect.fn("OpenAIImages.parseResponse")(function* (
|
||||
const images = yield* Effect.forEach(decoded.data, (item, index) => {
|
||||
if (item.b64_json)
|
||||
return Effect.fromResult(Encoding.decodeBase64(item.b64_json)).pipe(
|
||||
Effect.mapError(() => invalidOutput(`OpenAI Images result ${index} contains invalid base64 data`)),
|
||||
Effect.mapError((cause) => output.invalid(`OpenAI Images result ${index} contains invalid base64 data`, cause)),
|
||||
Effect.map(
|
||||
(data) =>
|
||||
new GeneratedImage({
|
||||
@@ -241,9 +225,9 @@ const parseResponse = Effect.fn("OpenAIImages.parseResponse")(function* (
|
||||
item.revised_prompt === undefined ? undefined : { openai: { revisedPrompt: item.revised_prompt } },
|
||||
}),
|
||||
)
|
||||
return Effect.fail(invalidOutput(`OpenAI Images result ${index} has neither image data nor a URL`))
|
||||
return Effect.fail(output.invalid(`OpenAI Images result ${index} has neither image data nor a URL`))
|
||||
})
|
||||
if (images.length === 0) return yield* invalidOutput("OpenAI Images returned no images")
|
||||
if (images.length === 0) return yield* output.invalid("OpenAI Images returned no images")
|
||||
return new ImageResponse({
|
||||
images,
|
||||
usage:
|
||||
|
||||
@@ -149,7 +149,9 @@ const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function*
|
||||
const isError = item.error !== undefined && item.error !== null
|
||||
if (item.type === "image_generation_call" && item.result) {
|
||||
yield* Effect.fromResult(Encoding.decodeBase64(item.result)).pipe(
|
||||
Effect.mapError(() => ProviderShared.eventError(ADAPTER, "OpenAI Responses returned invalid image base64")),
|
||||
Effect.mapError((cause) =>
|
||||
ProviderShared.eventError(ADAPTER, "OpenAI Responses returned invalid image base64", undefined, cause),
|
||||
),
|
||||
)
|
||||
const format = item.output_format ?? "png"
|
||||
return {
|
||||
|
||||
@@ -2,11 +2,12 @@ import { Buffer } from "node:buffer"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import * as Sse from "effect/unstable/encoding/Sse"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import { Headers, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import {
|
||||
InvalidProviderOutputReason,
|
||||
InvalidRequestReason,
|
||||
InvalidProviderOutputError,
|
||||
InvalidRequestError,
|
||||
AIError,
|
||||
HttpContext,
|
||||
type ContentPart,
|
||||
type LLMRequest,
|
||||
type MediaPart,
|
||||
@@ -96,17 +97,15 @@ export const sumTokens = (...values: ReadonlyArray<number | undefined>): number
|
||||
return values.reduce((acc: number, value) => acc + (value ?? 0), 0)
|
||||
}
|
||||
|
||||
export const eventError = (route: string, message: string, raw?: string) =>
|
||||
export const eventError = (route: string, message: string, body?: string, cause?: unknown) =>
|
||||
new AIError({
|
||||
module: "ProviderShared",
|
||||
method: "stream",
|
||||
reason: new InvalidProviderOutputReason({ route, message, raw }),
|
||||
reason: new InvalidProviderOutputError({ route, message, body, cause }),
|
||||
})
|
||||
|
||||
export const parseJson = (route: string, input: string, message: string) =>
|
||||
Effect.try({
|
||||
try: () => decodeJson(input),
|
||||
catch: () => eventError(route, message, input),
|
||||
catch: (cause) => eventError(route, message, input, cause),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -233,7 +232,7 @@ export const sseFraming = (
|
||||
(state, chunk) =>
|
||||
Effect.gen(function* () {
|
||||
const error = state.parser.feed(chunk)
|
||||
if (error) return yield* eventError("sse", error.message)
|
||||
if (error) return yield* eventError("sse", error.message, chunk, error)
|
||||
return [state, state.output.splice(0)] as const
|
||||
}),
|
||||
),
|
||||
@@ -249,13 +248,39 @@ export const sseFraming = (
|
||||
/**
|
||||
* Canonical invalid-request constructor shared by protocol lowering.
|
||||
*/
|
||||
export const invalidRequest = (message: string) =>
|
||||
export const invalidRequest = (message: string, cause?: unknown) =>
|
||||
new AIError({
|
||||
module: "ProviderShared",
|
||||
method: "request",
|
||||
reason: new InvalidRequestReason({ message }),
|
||||
reason: new InvalidRequestError({ message, cause }),
|
||||
})
|
||||
|
||||
export const imageResponse = Effect.fn("ProviderShared.imageResponse")(function* (
|
||||
route: string,
|
||||
name: string,
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
) {
|
||||
const http = new HttpContext({ url: response.request.url, status: response.status, headers: response.headers })
|
||||
const body = yield* response.text.pipe(
|
||||
Effect.mapError(
|
||||
(cause) =>
|
||||
new AIError({
|
||||
reason: new InvalidProviderOutputError({
|
||||
route,
|
||||
message: `Failed to read the ${name} response`,
|
||||
http,
|
||||
cause,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
return {
|
||||
body,
|
||||
invalid: (message: string, cause?: unknown) =>
|
||||
new AIError({
|
||||
reason: new InvalidProviderOutputError({ route, message, body, http, cause }),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
export const matchToolChoice = <Auto, None, Required, Tool>(
|
||||
route: string,
|
||||
toolChoice: NonNullable<LLMRequest["toolChoice"]>,
|
||||
@@ -302,7 +327,7 @@ export const unsupportedContent = (
|
||||
export const validateWith =
|
||||
<A, I, E extends { readonly message: string }>(decode: (input: I) => Effect.Effect<A, E>) =>
|
||||
(payload: I) =>
|
||||
decode(payload).pipe(Effect.mapError((error) => invalidRequest(error.message)))
|
||||
decode(payload).pipe(Effect.mapError((error) => invalidRequest(error.message, error)))
|
||||
|
||||
/**
|
||||
* Build an HTTP POST with a JSON body. Sets `content-type: application/json`
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { Effect, Encoding } from "effect"
|
||||
import type { ImageInput } from "../../image.js"
|
||||
import { InvalidRequestReason, AIError } from "../../schema/index.js"
|
||||
import { InvalidRequestError, AIError } from "../../schema/index.js"
|
||||
|
||||
const invalid = (module: string, message: string) =>
|
||||
const invalid = (message: string, cause?: unknown) =>
|
||||
new AIError({
|
||||
module,
|
||||
method: "generate",
|
||||
reason: new InvalidRequestReason({ message }),
|
||||
reason: new InvalidRequestError({ message, cause }),
|
||||
})
|
||||
|
||||
export const dataUrl = (input: Extract<ImageInput, { readonly type: "bytes" }>) =>
|
||||
@@ -14,13 +12,12 @@ export const dataUrl = (input: Extract<ImageInput, { readonly type: "bytes" }>)
|
||||
|
||||
export const decodeDataUrl = (
|
||||
url: string,
|
||||
module: string,
|
||||
): Effect.Effect<{ readonly mediaType: string; readonly data: Uint8Array } | undefined, AIError> => {
|
||||
if (!url.startsWith("data:")) return Effect.undefined
|
||||
const match = /^data:([^;,]+);base64,(.*)$/s.exec(url)
|
||||
if (!match) return Effect.fail(invalid(module, "Image data URLs must contain a MIME type and base64 data"))
|
||||
if (!match) return Effect.fail(invalid("Image data URLs must contain a MIME type and base64 data"))
|
||||
return Effect.fromResult(Encoding.decodeBase64(match[2])).pipe(
|
||||
Effect.mapError(() => invalid(module, "Image data URL contains invalid base64 data")),
|
||||
Effect.mapError((cause) => invalid("Image data URL contains invalid base64 data", cause)),
|
||||
Effect.map((data) => ({ mediaType: match[1], data })),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,14 +2,7 @@ import { Effect, Encoding, Schema } from "effect"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import { GeneratedImage, ImageModel, ImageResponse, type ImageRequestFor, type ImageRoute } from "../image.js"
|
||||
import { Auth, type Definition as AuthDefinition } from "../route/auth.js"
|
||||
import {
|
||||
InvalidProviderOutputReason,
|
||||
AIError,
|
||||
Usage,
|
||||
mergeHttpOptions,
|
||||
mergeJsonRecords,
|
||||
type HttpOptions,
|
||||
} from "../schema/index.js"
|
||||
import { Usage, mergeHttpOptions, mergeJsonRecords, type HttpOptions } from "../schema/index.js"
|
||||
import { ProviderShared, optionalNull } from "./shared.js"
|
||||
import { ImageInputs } from "./utils/image-input.js"
|
||||
|
||||
@@ -94,13 +87,6 @@ const nativeOptions = (options: XAIImageOptions | undefined) => {
|
||||
}
|
||||
}
|
||||
|
||||
const invalidOutput = (message: string) =>
|
||||
new AIError({
|
||||
module: ADAPTER,
|
||||
method: "generate",
|
||||
reason: new InvalidProviderOutputReason({ message, route: ADAPTER }),
|
||||
})
|
||||
|
||||
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
|
||||
if (!query) return url
|
||||
const next = new URL(url)
|
||||
@@ -120,7 +106,7 @@ export const model = (input: ModelInput) => {
|
||||
return undefined
|
||||
})
|
||||
if (imageReferences.some((image) => image === undefined))
|
||||
return yield* ImageInputs.invalid(ADAPTER, "xAI Images accepts image URLs, data URLs, bytes, and file IDs")
|
||||
return yield* ImageInputs.invalid("xAI Images accepts image URLs, data URLs, bytes, and file IDs")
|
||||
const requestBody = mergeJsonRecords(
|
||||
{
|
||||
model: request.model.id,
|
||||
@@ -149,17 +135,17 @@ export const model = (input: ModelInput) => {
|
||||
HttpClientRequest.bodyText(text, "application/json"),
|
||||
),
|
||||
)
|
||||
const payload = yield* response.json.pipe(
|
||||
Effect.mapError(() => invalidOutput("Failed to read the xAI Images response")),
|
||||
)
|
||||
const decoded = yield* Schema.decodeUnknownEffect(XAIImageResponse)(payload).pipe(
|
||||
Effect.mapError(() => invalidOutput("xAI Images returned an invalid response")),
|
||||
const output = yield* ProviderShared.imageResponse(ADAPTER, "xAI Images", response)
|
||||
const decoded = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(XAIImageResponse))(output.body).pipe(
|
||||
Effect.mapError((cause) => output.invalid("xAI Images returned an invalid response", cause)),
|
||||
)
|
||||
const images = yield* Effect.forEach(decoded.data, (item, index) => {
|
||||
const mediaType = item.mime_type ?? "application/octet-stream"
|
||||
if (item.b64_json)
|
||||
return Effect.fromResult(Encoding.decodeBase64(item.b64_json)).pipe(
|
||||
Effect.mapError(() => invalidOutput(`xAI Images result ${index} contains invalid base64 data`)),
|
||||
Effect.mapError((cause) =>
|
||||
output.invalid(`xAI Images result ${index} contains invalid base64 data`, cause),
|
||||
),
|
||||
Effect.map(
|
||||
(data) =>
|
||||
new GeneratedImage({
|
||||
@@ -183,9 +169,9 @@ export const model = (input: ModelInput) => {
|
||||
: { xai: { revisedPrompt: item.revised_prompt } },
|
||||
}),
|
||||
)
|
||||
return Effect.fail(invalidOutput(`xAI Images result ${index} has neither image data nor a URL`))
|
||||
return Effect.fail(output.invalid(`xAI Images result ${index} has neither image data nor a URL`))
|
||||
})
|
||||
if (images.length === 0) return yield* invalidOutput("xAI Images returned no images")
|
||||
if (images.length === 0) return yield* output.invalid("xAI Images returned no images")
|
||||
const usage = ProviderShared.isRecord(decoded.usage) ? decoded.usage : undefined
|
||||
return new ImageResponse({
|
||||
images,
|
||||
|
||||
@@ -2,13 +2,7 @@ import { Effect, Schema } from "effect"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import { GeneratedImage, ImageModel, ImageResponse, type ImageRequestFor, type ImageRoute } from "../image.js"
|
||||
import { Auth, type Definition as AuthDefinition } from "../route/auth.js"
|
||||
import {
|
||||
InvalidProviderOutputReason,
|
||||
AIError,
|
||||
mergeHttpOptions,
|
||||
mergeJsonRecords,
|
||||
type HttpOptions,
|
||||
} from "../schema/index.js"
|
||||
import { mergeHttpOptions, mergeJsonRecords, type HttpOptions } from "../schema/index.js"
|
||||
import { ProviderShared } from "./shared.js"
|
||||
import { ImageInputs } from "./utils/image-input.js"
|
||||
|
||||
@@ -63,13 +57,6 @@ const nativeOptions = (options: ZAIImageOptions | undefined) => {
|
||||
}
|
||||
}
|
||||
|
||||
const invalidOutput = (message: string) =>
|
||||
new AIError({
|
||||
module: ADAPTER,
|
||||
method: "generate",
|
||||
reason: new InvalidProviderOutputReason({ message, route: ADAPTER }),
|
||||
})
|
||||
|
||||
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
|
||||
if (!query) return url
|
||||
const next = new URL(url)
|
||||
@@ -82,7 +69,7 @@ export const model = (input: ModelInput) => {
|
||||
id: ADAPTER,
|
||||
generate: Effect.fn("ZAIImages.generate")(function* (request: ImageRequestFor<ZAIImageOptions>, execute) {
|
||||
if ((request.images?.length ?? 0) > 0)
|
||||
return yield* ImageInputs.invalid(ADAPTER, "Z.ai hosted image generation does not support image inputs")
|
||||
return yield* ImageInputs.invalid("Z.ai hosted image generation does not support image inputs")
|
||||
const http = mergeHttpOptions(request.model.http, request.http)
|
||||
const requestBody = mergeJsonRecords(
|
||||
{ model: request.model.id, prompt: request.prompt },
|
||||
@@ -104,13 +91,11 @@ export const model = (input: ModelInput) => {
|
||||
HttpClientRequest.bodyText(text, "application/json"),
|
||||
),
|
||||
)
|
||||
const payload = yield* response.json.pipe(
|
||||
Effect.mapError(() => invalidOutput("Failed to read the Z.ai Images response")),
|
||||
const output = yield* ProviderShared.imageResponse(ADAPTER, "Z.ai Images", response)
|
||||
const decoded = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(ZAIImageResponse))(output.body).pipe(
|
||||
Effect.mapError((cause) => output.invalid("Z.ai Images returned an invalid response", cause)),
|
||||
)
|
||||
const decoded = yield* Schema.decodeUnknownEffect(ZAIImageResponse)(payload).pipe(
|
||||
Effect.mapError(() => invalidOutput("Z.ai Images returned an invalid response")),
|
||||
)
|
||||
if (decoded.data.length === 0) return yield* invalidOutput("Z.ai Images returned no images")
|
||||
if (decoded.data.length === 0) return yield* output.invalid("Z.ai Images returned no images")
|
||||
return new ImageResponse({
|
||||
images: decoded.data.map(
|
||||
(item) =>
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { Option, Schema } from "effect"
|
||||
import {
|
||||
AuthenticationReason,
|
||||
ContentPolicyReason,
|
||||
InvalidRequestReason,
|
||||
AuthenticationError,
|
||||
ContentPolicyError,
|
||||
InvalidRequestError,
|
||||
AIError,
|
||||
ProviderErrorEvent,
|
||||
ProviderInternalReason,
|
||||
QuotaExceededReason,
|
||||
RateLimitReason,
|
||||
UnknownProviderReason,
|
||||
ProviderInternalError,
|
||||
QuotaExceededError,
|
||||
RateLimitError,
|
||||
UnknownProviderError,
|
||||
type HttpContext,
|
||||
type HttpRateLimitDetails,
|
||||
type ProviderMetadata,
|
||||
} from "./schema/index.js"
|
||||
|
||||
const patterns = [
|
||||
@@ -80,27 +79,28 @@ const NETWORK_ERROR_TEXT = /network[-_\s]error/i
|
||||
export interface ProviderFailure {
|
||||
readonly message: string
|
||||
readonly status?: number | undefined
|
||||
readonly code?: string | undefined
|
||||
// Raw wire payload, scanned for failure signals (codes, overflow phrases)
|
||||
// that the summary message does not carry. Not shown to users.
|
||||
readonly rawBody?: string | undefined
|
||||
// Some SDKs supply parsed error data separately from the original response text.
|
||||
readonly data?: unknown
|
||||
readonly http?: HttpContext | undefined
|
||||
readonly cause?: unknown
|
||||
readonly retryAfterMs?: number | undefined
|
||||
readonly rateLimit?: HttpRateLimitDetails | undefined
|
||||
readonly http?: HttpContext | undefined
|
||||
readonly providerMetadata?: ProviderMetadata | undefined
|
||||
}
|
||||
|
||||
// Keep HTTP failures and provider-reported stream failures on one typed path so
|
||||
// session retry policy never needs provider-specific string matching.
|
||||
export function classifyProviderFailure(input: ProviderFailure): AIError["reason"] {
|
||||
const body = input.http?.body ?? input.rawBody ?? ""
|
||||
const codes = [input.code, ...providerCodes(body), ...providerCodes(input.message)]
|
||||
.filter((code): code is string => code !== undefined)
|
||||
.map((code) => code.toLowerCase())
|
||||
const details = { message: input.message, body: input.rawBody, http: input.http, cause: input.cause }
|
||||
const body = input.rawBody ?? ""
|
||||
const codes = [...providerCodes(input.data), ...providerCodes(body), ...providerCodes(input.message)].map((code) =>
|
||||
code.toLowerCase(),
|
||||
)
|
||||
// Scan the raw payload too so signals missing from the summary message
|
||||
// (e.g. overflow phrases nested in a JSON error body) still classify.
|
||||
const text = [input.message, body].filter((value) => value.length > 0).join("\n")
|
||||
const common = { message: input.message, providerMetadata: input.providerMetadata, http: input.http }
|
||||
const clientScoped = input.status === undefined || (input.status >= 400 && input.status < 500)
|
||||
|
||||
if (
|
||||
@@ -110,62 +110,65 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
|
||||
codes.includes("request_too_large") ||
|
||||
isContextOverflow(text))
|
||||
)
|
||||
return new InvalidRequestReason({ ...common, classification: "context-overflow" })
|
||||
return new InvalidRequestError({ ...details, classification: "context-overflow" })
|
||||
if (input.status === 413 || isPayloadTooLarge(text))
|
||||
return new InvalidRequestReason({ ...common, classification: "payload-too-large" })
|
||||
if (CONTENT_POLICY_TEXT.test(text)) return new ContentPolicyReason(common)
|
||||
return new InvalidRequestError({ ...details, classification: "payload-too-large" })
|
||||
if (CONTENT_POLICY_TEXT.test(text)) return new ContentPolicyError(details)
|
||||
if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text)))
|
||||
return new QuotaExceededReason(common)
|
||||
if (input.status === 401) return new AuthenticationReason({ ...common, kind: "invalid" })
|
||||
if (input.status === 403) return new AuthenticationReason({ ...common, kind: "insufficient-permissions" })
|
||||
if (codes.includes("authentication_error")) return new AuthenticationReason({ ...common, kind: "invalid" })
|
||||
return new QuotaExceededError(details)
|
||||
if (input.status === 401) return new AuthenticationError({ ...details, kind: "invalid" })
|
||||
if (input.status === 403) return new AuthenticationError({ ...details, kind: "insufficient-permissions" })
|
||||
if (codes.includes("authentication_error")) return new AuthenticationError({ ...details, kind: "invalid" })
|
||||
if (codes.includes("permission_error"))
|
||||
return new AuthenticationReason({ ...common, kind: "insufficient-permissions" })
|
||||
return new AuthenticationError({ ...details, kind: "insufficient-permissions" })
|
||||
if (
|
||||
codes.some((code) => code.includes("rate_limit") || code === "too_many_requests" || code === "throttlingexception")
|
||||
)
|
||||
return new RateLimitReason({
|
||||
...common,
|
||||
return new RateLimitError({
|
||||
...details,
|
||||
retryAfterMs: input.retryAfterMs,
|
||||
rateLimit: input.rateLimit,
|
||||
})
|
||||
if (RATE_LIMIT_TEXT.test(text))
|
||||
return new RateLimitReason({
|
||||
...common,
|
||||
return new RateLimitError({
|
||||
...details,
|
||||
retryAfterMs: input.retryAfterMs,
|
||||
rateLimit: input.rateLimit,
|
||||
})
|
||||
if (NETWORK_ERROR_TEXT.test(text)) return new ProviderInternalReason({ ...common, status: input.status })
|
||||
if (NETWORK_ERROR_TEXT.test(text)) return new ProviderInternalError(details)
|
||||
if (codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable")))
|
||||
return new ProviderInternalReason({
|
||||
...common,
|
||||
status: input.status,
|
||||
return new ProviderInternalError({
|
||||
...details,
|
||||
retryAfterMs: input.retryAfterMs,
|
||||
})
|
||||
if (input.status === 429) {
|
||||
return new RateLimitReason({
|
||||
...common,
|
||||
return new RateLimitError({
|
||||
...details,
|
||||
retryAfterMs: input.retryAfterMs,
|
||||
rateLimit: input.rateLimit,
|
||||
})
|
||||
}
|
||||
if (input.status === 408 || input.status === 409 || (input.status !== undefined && input.status >= 500))
|
||||
return new ProviderInternalReason({
|
||||
...common,
|
||||
status: input.status,
|
||||
return new ProviderInternalError({
|
||||
...details,
|
||||
retryAfterMs: input.retryAfterMs,
|
||||
})
|
||||
if (codes.some((code) => INVALID_REQUEST_CODES.has(code))) return new InvalidRequestReason(common)
|
||||
if (codes.some((code) => INVALID_REQUEST_CODES.has(code))) return new InvalidRequestError(details)
|
||||
if (input.status === 400 || input.status === 404 || input.status === 413 || input.status === 422)
|
||||
return new InvalidRequestReason(common)
|
||||
return new UnknownProviderReason({ ...common, status: input.status })
|
||||
return new InvalidRequestError(details)
|
||||
return new UnknownProviderError(details)
|
||||
}
|
||||
|
||||
function providerCodes(value: string) {
|
||||
const decoded = Option.getOrUndefined(decodeJson(value))
|
||||
function providerCodes(value: unknown) {
|
||||
const decoded = typeof value === "string" ? Option.getOrUndefined(decodeJson(value)) : value
|
||||
if (!isRecord(decoded)) return []
|
||||
const error = isRecord(decoded.error) ? decoded.error : undefined
|
||||
return [decoded.code, error?.code, error?.type].filter((value): value is string => typeof value === "string")
|
||||
const response = isRecord(decoded.response) ? decoded.response : undefined
|
||||
const responseError = response && isRecord(response.error) ? response.error : undefined
|
||||
const exception = isRecord(decoded.exception) ? decoded.exception : undefined
|
||||
return [decoded.code, error?.code, error?.type, error?.status, responseError?.code, exception?.type].filter(
|
||||
(value): value is string => typeof value === "string",
|
||||
)
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Config, Effect, Redacted } from "effect"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { AuthenticationReason, InvalidRequestReason, AIError, type HttpOptions } from "../schema/index.js"
|
||||
import { AuthenticationError, InvalidRequestError, AIError, type HttpOptions } from "../schema/index.js"
|
||||
|
||||
export class MissingCredentialError extends Error {
|
||||
readonly _tag = "MissingCredentialError"
|
||||
@@ -137,12 +137,10 @@ export function bearerHeader(name: string, source?: Secret | Credential) {
|
||||
const toAIError = (error: AuthError): AIError => {
|
||||
if (error instanceof MissingCredentialError || error instanceof Config.ConfigError) {
|
||||
return new AIError({
|
||||
module: "Auth",
|
||||
method: "apply",
|
||||
reason:
|
||||
error instanceof MissingCredentialError
|
||||
? new AuthenticationReason({ message: error.message, kind: "missing" })
|
||||
: new InvalidRequestReason({ message: `Failed to resolve auth config: ${error.message}` }),
|
||||
? new AuthenticationError({ message: error.message, cause: error, kind: "missing" })
|
||||
: new InvalidRequestError({ message: `Failed to resolve auth config: ${error.message}`, cause: error }),
|
||||
})
|
||||
}
|
||||
return error
|
||||
|
||||
@@ -12,13 +12,14 @@ import * as ProviderShared from "../protocols/shared.js"
|
||||
import type { ProtocolID, ProviderOptions } from "../schema/index.js"
|
||||
import {
|
||||
AIError,
|
||||
AIErrorReason,
|
||||
GenerationOptions,
|
||||
HttpOptions,
|
||||
LLMRequest,
|
||||
LLMResponse,
|
||||
LanguageModel,
|
||||
LLMEvent,
|
||||
InvalidProviderOutputReason,
|
||||
InvalidProviderOutputError,
|
||||
ProviderID,
|
||||
mergeGenerationOptions,
|
||||
mergeHttpOptions,
|
||||
@@ -227,16 +228,14 @@ export interface MakeTransportInput<Body, Prepared, Frame, Event, State> {
|
||||
const streamError = (route: string, message: string, cause: Cause.Cause<unknown>) => {
|
||||
const failed = cause.reasons.find(Cause.isFailReason)?.error
|
||||
if (failed instanceof AIError) return failed
|
||||
return ProviderShared.eventError(route, message, Cause.pretty(cause))
|
||||
return ProviderShared.eventError(route, message, undefined, cause)
|
||||
}
|
||||
|
||||
const incompleteStreamError = (route: string) =>
|
||||
new AIError({
|
||||
module: "LLMClient",
|
||||
method: "stream",
|
||||
reason: new InvalidProviderOutputReason({
|
||||
classification: "incomplete-stream",
|
||||
reason: new InvalidProviderOutputError({
|
||||
message: "The provider response ended unexpectedly.",
|
||||
classification: "incomplete-stream",
|
||||
route,
|
||||
}),
|
||||
})
|
||||
@@ -265,11 +264,12 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
const decodeEventEffect = Schema.decodeUnknownEffect(protocol.stream.event)
|
||||
const decodeEvent = (route: string) => (frame: Frame) =>
|
||||
decodeEventEffect(frame).pipe(
|
||||
Effect.mapError(() =>
|
||||
Effect.mapError((cause) =>
|
||||
ProviderShared.eventError(
|
||||
input.id,
|
||||
`Invalid ${route} stream event`,
|
||||
typeof frame === "string" ? frame : ProviderShared.encodeJson(frame),
|
||||
cause,
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -324,19 +324,48 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
return Stream.unwrap(
|
||||
routeInput.transport.execute(prepared, request, runtime, options).pipe(
|
||||
Effect.map((execution) => {
|
||||
const terminal = protocol.stream.terminal
|
||||
// Preserve assembled inputs; replace only serialized event fallbacks with their original wire data.
|
||||
const frameError =
|
||||
(frame: Frame, event: Frame | Event = frame) =>
|
||||
(error: AIError) =>
|
||||
new AIError({
|
||||
reason: AIErrorReason.make({
|
||||
...error.reason,
|
||||
message: error.reason.message,
|
||||
cause: error.reason.cause,
|
||||
body:
|
||||
error.reason.body !== undefined && error.reason.body !== ProviderShared.encodeJson(event)
|
||||
? error.reason.body
|
||||
: (execution.body?.(frame) ??
|
||||
(typeof frame === "string" ? frame : ProviderShared.encodeJson(frame))),
|
||||
}),
|
||||
})
|
||||
const events = execution.frames.pipe(
|
||||
Stream.mapEffect(decodeEvent(route)),
|
||||
protocol.stream.terminal ? Stream.takeUntil(protocol.stream.terminal) : (stream) => stream,
|
||||
Stream.mapEffect((frame) =>
|
||||
decodeEvent(route)(frame).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.fail(streamError(route, `Failed to decode ${route} event`, cause)),
|
||||
),
|
||||
Effect.map((event) => ({ event, frame })),
|
||||
Effect.mapError(frameError(frame)),
|
||||
),
|
||||
),
|
||||
terminal ? Stream.takeUntil(({ event }) => terminal(event)) : (stream) => stream,
|
||||
)
|
||||
const stream = Stream.suspend(() => {
|
||||
let state = protocol.stream.initial(request)
|
||||
const parsed = events.pipe(
|
||||
Stream.mapEffect((event) =>
|
||||
Stream.mapEffect(({ event, frame }) =>
|
||||
protocol.stream.step(state, event).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.fail(streamError(route, `Failed to parse ${route} event`, cause)),
|
||||
),
|
||||
Effect.map(([next, output]) => {
|
||||
state = next
|
||||
return output
|
||||
}),
|
||||
Effect.mapError(frameError(frame, event)),
|
||||
),
|
||||
),
|
||||
Stream.flatMap(Stream.fromIterable),
|
||||
@@ -352,6 +381,17 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
}).pipe(
|
||||
Stream.catchCause((cause) => Stream.fail(streamError(route, `Failed to read ${route} stream`, cause))),
|
||||
requireTerminalEvent(route),
|
||||
Stream.mapError(
|
||||
(error) =>
|
||||
new AIError({
|
||||
reason: AIErrorReason.make({
|
||||
...error.reason,
|
||||
message: error.reason.message,
|
||||
cause: error.reason.cause,
|
||||
http: error.reason.http ?? execution.http,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
return execution.complete ? stream.pipe(Stream.onEnd(execution.complete)) : stream
|
||||
}),
|
||||
|
||||
@@ -7,14 +7,7 @@ import {
|
||||
HttpClientRequest,
|
||||
HttpClientResponse,
|
||||
} from "effect/unstable/http"
|
||||
import {
|
||||
HttpContext,
|
||||
HttpRateLimitDetails,
|
||||
HttpRequestDetails,
|
||||
HttpResponseDetails,
|
||||
AIError,
|
||||
TransportReason,
|
||||
} from "../schema/index.js"
|
||||
import { HttpContext, HttpRateLimitDetails, AIError, TransportError } from "../schema/index.js"
|
||||
import { classifyProviderFailure } from "../provider-error.js"
|
||||
|
||||
export interface Interface {
|
||||
@@ -97,24 +90,13 @@ const rateLimitDetails = (headers: Record<string, string>, retryAfter: number |
|
||||
})
|
||||
}
|
||||
|
||||
const requestDetails = (request: HttpClientRequest.HttpClientRequest) =>
|
||||
new HttpRequestDetails({
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
headers: headerDetails(request.headers),
|
||||
})
|
||||
|
||||
const responseDetails = (response: HttpClientResponse.HttpClientResponse) =>
|
||||
new HttpResponseDetails({
|
||||
export const responseHttp = (response: HttpClientResponse.HttpClientResponse) =>
|
||||
new HttpContext({
|
||||
url: response.request.url,
|
||||
status: response.status,
|
||||
headers: headerDetails(response.headers),
|
||||
})
|
||||
|
||||
const responseBody = (body: string | void) => {
|
||||
if (body === undefined) return {}
|
||||
return { body }
|
||||
}
|
||||
|
||||
const decodeProviderBody = Schema.decodeUnknownOption(
|
||||
Schema.fromJsonString(
|
||||
Schema.Struct({
|
||||
@@ -132,77 +114,46 @@ const providerMessage = (status: number, body: string | void) => {
|
||||
)
|
||||
}
|
||||
|
||||
const responseHttp = (input: {
|
||||
readonly request: HttpClientRequest.HttpClientRequest
|
||||
readonly response: HttpClientResponse.HttpClientResponse
|
||||
readonly body: ReturnType<typeof responseBody>
|
||||
readonly rateLimit?: HttpRateLimitDetails | undefined
|
||||
}) =>
|
||||
new HttpContext({
|
||||
request: requestDetails(input.request),
|
||||
response: responseDetails(input.response),
|
||||
...input.body,
|
||||
rateLimit: input.rateLimit,
|
||||
const statusError = (response: HttpClientResponse.HttpClientResponse) =>
|
||||
Effect.gen(function* () {
|
||||
if (response.status < 400) return response
|
||||
const result = yield* response.text.pipe(Effect.result)
|
||||
return yield* httpFailure({
|
||||
message: providerMessage(response.status, result._tag === "Success" ? result.success : undefined),
|
||||
url: response.request.url,
|
||||
status: response.status,
|
||||
responseHeaders: headerDetails(response.headers),
|
||||
responseBody: result._tag === "Success" ? result.success : undefined,
|
||||
cause: result._tag === "Failure" ? (result.failure.cause ?? result.failure) : undefined,
|
||||
})
|
||||
})
|
||||
|
||||
const statusError =
|
||||
(request: HttpClientRequest.HttpClientRequest) => (response: HttpClientResponse.HttpClientResponse) =>
|
||||
Effect.gen(function* () {
|
||||
if (response.status < 400) return response
|
||||
const body = yield* response.text.pipe(Effect.catch(() => Effect.void))
|
||||
const headers = normalizedHeaders(response.headers)
|
||||
const retryAfter = retryAfterMs(headers)
|
||||
const rateLimit = rateLimitDetails(headers, retryAfter)
|
||||
const details = responseBody(body)
|
||||
return yield* new AIError({
|
||||
module: "RequestExecutor",
|
||||
method: "execute",
|
||||
reason: classifyProviderFailure({
|
||||
status: response.status,
|
||||
message: providerMessage(response.status, body),
|
||||
retryAfterMs: retryAfter,
|
||||
rateLimit,
|
||||
http: responseHttp({
|
||||
request,
|
||||
response,
|
||||
body: details,
|
||||
rateLimit,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
// Classifies an HTTP failure captured outside the executor (for example by the
|
||||
// AI SDK's own fetch) onto the same reason types and HttpContext that
|
||||
// executor-driven requests produce. The originating request is not available on
|
||||
// that path, so the method is assumed (language model calls are always POST),
|
||||
// request headers are empty.
|
||||
export const classifyHttpFailure = (input: {
|
||||
/** Preserve HTTP diagnostics for executor and externally captured failures alike. */
|
||||
export const httpFailure = (input: {
|
||||
readonly message: string
|
||||
readonly url: string
|
||||
readonly url?: string | undefined
|
||||
readonly status?: number | undefined
|
||||
readonly code?: string | undefined
|
||||
readonly data?: unknown
|
||||
readonly responseHeaders?: Record<string, string> | undefined
|
||||
readonly responseBody?: string | undefined
|
||||
readonly cause?: unknown
|
||||
}) => {
|
||||
const headers = normalizedHeaders(Headers.fromInput(input.responseHeaders))
|
||||
const retryAfter = retryAfterMs(headers)
|
||||
const rateLimit = rateLimitDetails(headers, retryAfter)
|
||||
const details = responseBody(input.responseBody)
|
||||
return classifyProviderFailure({
|
||||
message: input.message,
|
||||
status: input.status,
|
||||
code: input.code,
|
||||
retryAfterMs: retryAfter,
|
||||
rateLimit,
|
||||
http: new HttpContext({
|
||||
request: new HttpRequestDetails({ method: "POST", url: input.url, headers: {} }),
|
||||
response:
|
||||
input.status === undefined
|
||||
? undefined
|
||||
: new HttpResponseDetails({ status: input.status, headers: headerDetails(Headers.fromInput(headers)) }),
|
||||
...details,
|
||||
return new AIError({
|
||||
reason: classifyProviderFailure({
|
||||
message: input.message,
|
||||
status: input.status,
|
||||
data: input.data,
|
||||
rawBody: input.responseBody,
|
||||
retryAfterMs: retryAfter,
|
||||
rateLimit,
|
||||
cause: input.cause,
|
||||
http:
|
||||
input.status === undefined || input.url === undefined
|
||||
? undefined
|
||||
: new HttpContext({ url: input.url, status: input.status, headers }),
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -229,25 +180,25 @@ const httpError = (input: {
|
||||
readonly error: unknown
|
||||
readonly request: HttpClientRequest.HttpClientRequest
|
||||
readonly operation: HttpOperation
|
||||
readonly http?: HttpContext
|
||||
}) => {
|
||||
const request = HttpClientError.isHttpClientError(input.error) ? input.error.request : input.request
|
||||
const transportError = (failure: { readonly message: string; readonly code?: string | undefined }) =>
|
||||
new AIError({
|
||||
module: "RequestExecutor",
|
||||
method: input.operation,
|
||||
reason: new TransportReason({
|
||||
reason: new TransportError({
|
||||
message: failure.message,
|
||||
cause: source,
|
||||
http: input.http,
|
||||
transport: "http",
|
||||
operation: input.operation,
|
||||
code: failure.code,
|
||||
url: request.url,
|
||||
http: new HttpContext({ request: requestDetails(request) }),
|
||||
}),
|
||||
})
|
||||
|
||||
const source =
|
||||
HttpClientError.isHttpClientError(input.error) && "cause" in input.error.reason
|
||||
? input.error.reason.cause
|
||||
? (input.error.reason.cause ?? input.error)
|
||||
: input.error
|
||||
const native = nativeTransportFailure(source)
|
||||
const code = native?.code
|
||||
@@ -271,6 +222,13 @@ const httpError = (input: {
|
||||
})
|
||||
}
|
||||
|
||||
export const responseStream = (response: HttpClientResponse.HttpClientResponse): Stream.Stream<Uint8Array, AIError> =>
|
||||
response.stream.pipe(
|
||||
Stream.mapError((error) =>
|
||||
httpError({ error, request: response.request, operation: "read", http: responseHttp(response) }),
|
||||
),
|
||||
)
|
||||
|
||||
export const stream = (
|
||||
executor: Interface,
|
||||
request: HttpClientRequest.HttpClientRequest,
|
||||
@@ -279,9 +237,7 @@ export const stream = (
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const response = yield* executor.execute(request, middleware)
|
||||
return response.stream.pipe(
|
||||
Stream.mapError((error) => httpError({ error, request: response.request, operation: "read" })),
|
||||
)
|
||||
return responseStream(response)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -294,7 +250,7 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.e
|
||||
if (!middleware)
|
||||
return yield* http.execute(request).pipe(
|
||||
Effect.mapError((error) => httpError({ error, request, operation: "request" })),
|
||||
Effect.flatMap(statusError(request)),
|
||||
Effect.flatMap(statusError),
|
||||
)
|
||||
|
||||
const response = yield* middleware(request, (input) =>
|
||||
@@ -302,7 +258,7 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.e
|
||||
.execute(input)
|
||||
.pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
|
||||
).pipe(Effect.mapError((error) => httpError({ error, request, operation: "request" })))
|
||||
return yield* statusError(response.request)(response)
|
||||
return yield* statusError(response)
|
||||
})
|
||||
return Service.of({
|
||||
execute: executeOnce,
|
||||
|
||||
@@ -19,6 +19,8 @@ import type { AIError } from "../schema/index.js"
|
||||
export interface Definition<Frame> {
|
||||
readonly id: string
|
||||
readonly frame: (bytes: Stream.Stream<Uint8Array, AIError>) => Stream.Stream<Frame, AIError>
|
||||
/** Original wire representation when framing transforms the provider payload. */
|
||||
readonly body?: (frame: Frame) => string | undefined
|
||||
}
|
||||
|
||||
/** Server-Sent Events framing. Used by every JSON-streaming HTTP provider. */
|
||||
|
||||
@@ -88,8 +88,13 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
|
||||
}
|
||||
}),
|
||||
execute: (prepared, _request, runtime) =>
|
||||
Effect.succeed({
|
||||
frames: prepared.framing.frame(RequestExecutor.stream(runtime.http, prepared.request, prepared.middleware)),
|
||||
Effect.gen(function* () {
|
||||
const response = yield* runtime.http.execute(prepared.request, prepared.middleware)
|
||||
return {
|
||||
frames: prepared.framing.frame(RequestExecutor.responseStream(response)),
|
||||
http: RequestExecutor.responseHttp(response),
|
||||
body: prepared.framing.body,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Endpoint } from "../endpoint.js"
|
||||
import { Auth } from "../auth.js"
|
||||
import type { HttpMiddleware, Interface as RequestExecutorInterface } from "../executor.js"
|
||||
import type { WebSocketChannelExecutor } from "./websocket-channel.js"
|
||||
import type { AIError, LLMRequest } from "../../schema/index.js"
|
||||
import type { AIError, HttpContext, LLMRequest } from "../../schema/index.js"
|
||||
|
||||
export interface TransportRuntime {
|
||||
readonly http: RequestExecutorInterface
|
||||
@@ -11,6 +11,8 @@ export interface TransportRuntime {
|
||||
|
||||
export interface TransportExecution<Frame> {
|
||||
readonly frames: Stream.Stream<Frame, AIError>
|
||||
readonly http?: HttpContext
|
||||
body?(frame: Frame): string | undefined
|
||||
/** Optional successful-consumption acknowledgement. HTTP leaves this absent. */
|
||||
readonly complete?: Effect.Effect<void>
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Effect, Scope, Stream } from "effect"
|
||||
import type { Headers } from "effect/unstable/http"
|
||||
import type { AIError } from "../../schema/index.js"
|
||||
import type { AIError, HttpContext } from "../../schema/index.js"
|
||||
|
||||
export interface WebSocketChannelExecutor {
|
||||
readonly execute: (
|
||||
@@ -10,6 +10,7 @@ export interface WebSocketChannelExecutor {
|
||||
|
||||
export interface WebSocketChannelExecution {
|
||||
readonly frames: Stream.Stream<string, AIError>
|
||||
readonly http?: HttpContext
|
||||
/** Commits staged state after the decoded Route stream ends successfully. */
|
||||
readonly complete: Effect.Effect<void>
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { Cause, Effect, Queue, Stream } from "effect"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { Socket } from "effect/unstable/socket"
|
||||
import { AIError, TransportReason, type TransportOperation } from "../../schema/index.js"
|
||||
import {
|
||||
AIError,
|
||||
AIErrorReason,
|
||||
TransportError,
|
||||
type HttpContext,
|
||||
type TransportOperation,
|
||||
} from "../../schema/index.js"
|
||||
import * as HttpTransport from "./http.js"
|
||||
import type { Transport } from "./index.js"
|
||||
import type {
|
||||
@@ -17,6 +23,7 @@ export interface WebSocketRequest {
|
||||
}
|
||||
|
||||
export interface WebSocketConnection {
|
||||
readonly http?: HttpContext
|
||||
readonly sendText: (message: string) => Effect.Effect<void, AIError>
|
||||
readonly messages: Stream.Stream<string | Uint8Array, AIError>
|
||||
readonly close: Effect.Effect<void, never>
|
||||
@@ -33,21 +40,22 @@ type WebSocketConstructorWithHeaders = (
|
||||
|
||||
const MAX_FRAME_BYTES = 16 * 1024 * 1024
|
||||
const transportError = (
|
||||
method: string,
|
||||
message: string,
|
||||
input: {
|
||||
readonly operation: TransportOperation
|
||||
readonly url?: string
|
||||
readonly code?: string
|
||||
readonly phase?: TransportReason["phase"]
|
||||
readonly delivery?: TransportReason["delivery"]
|
||||
readonly phase?: TransportError["phase"]
|
||||
readonly delivery?: TransportError["delivery"]
|
||||
readonly body?: string
|
||||
readonly cause?: unknown
|
||||
},
|
||||
) =>
|
||||
new AIError({
|
||||
module: "WebSocketConnector",
|
||||
method,
|
||||
reason: new TransportReason({
|
||||
reason: new TransportError({
|
||||
message,
|
||||
body: input.body,
|
||||
cause: input.cause,
|
||||
transport: "websocket",
|
||||
operation: input.operation,
|
||||
url: input.url,
|
||||
@@ -59,22 +67,16 @@ const transportError = (
|
||||
|
||||
const annotateTransportError = (
|
||||
error: AIError,
|
||||
input: { readonly phase: TransportReason["phase"]; readonly delivery: TransportReason["delivery"] },
|
||||
input: { readonly phase: TransportError["phase"]; readonly delivery: TransportError["delivery"] },
|
||||
) =>
|
||||
error.reason._tag === "Transport"
|
||||
? new AIError({
|
||||
module: error.module,
|
||||
method: error.method,
|
||||
reason: new TransportReason({
|
||||
reason: new TransportError({
|
||||
...error.reason,
|
||||
message: error.reason.message,
|
||||
transport: error.reason.transport,
|
||||
operation: error.reason.operation,
|
||||
code: error.reason.code,
|
||||
url: error.reason.url,
|
||||
http: error.reason.http,
|
||||
cause: error.reason.cause,
|
||||
phase: input.phase,
|
||||
delivery: input.delivery,
|
||||
recovery: error.reason.recovery,
|
||||
}),
|
||||
})
|
||||
: error
|
||||
@@ -95,7 +97,7 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
|
||||
if (ws.readyState === globalThis.WebSocket.OPEN) return Effect.void
|
||||
if (ws.readyState === globalThis.WebSocket.CLOSING || ws.readyState === globalThis.WebSocket.CLOSED) {
|
||||
return Effect.fail(
|
||||
transportError("open", `WebSocket closed before opening (state ${ws.readyState})`, {
|
||||
transportError(`WebSocket closed before opening (state ${ws.readyState})`, {
|
||||
url: input.url,
|
||||
operation: "request",
|
||||
code: "closed",
|
||||
@@ -124,7 +126,8 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
|
||||
cleanup()
|
||||
resume(
|
||||
Effect.fail(
|
||||
transportError("open", `Failed to open WebSocket: ${eventMessage(event)}`, {
|
||||
transportError(`Failed to open WebSocket: ${eventMessage(event)}`, {
|
||||
cause: "error" in event ? (event.error ?? event) : event,
|
||||
url: input.url,
|
||||
operation: "request",
|
||||
phase: "connect",
|
||||
@@ -137,7 +140,9 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
|
||||
cleanup()
|
||||
resume(
|
||||
Effect.fail(
|
||||
transportError("open", `WebSocket closed before opening with code ${event.code}`, {
|
||||
transportError(`WebSocket closed before opening with code ${event.code}`, {
|
||||
body: event.reason,
|
||||
cause: event,
|
||||
url: input.url,
|
||||
operation: "request",
|
||||
code: String(event.code),
|
||||
@@ -169,7 +174,8 @@ export const toWebSocketUrl = (value: string) =>
|
||||
throw new Error(`Unsupported WebSocket URL protocol ${url.protocol}`)
|
||||
},
|
||||
catch: (error) =>
|
||||
transportError("prepare", error instanceof Error ? error.message : "Invalid WebSocket URL", {
|
||||
transportError(error instanceof Error ? error.message : "Invalid WebSocket URL", {
|
||||
cause: error,
|
||||
url: value,
|
||||
operation: "request",
|
||||
code: "invalid-url",
|
||||
@@ -189,7 +195,8 @@ export const open = (input: WebSocketRequest) =>
|
||||
headers: input.headers,
|
||||
}),
|
||||
catch: (error) =>
|
||||
transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", {
|
||||
transportError(error instanceof Error ? error.message : "Failed to construct WebSocket", {
|
||||
cause: error,
|
||||
url: input.url,
|
||||
operation: "request",
|
||||
phase: "connect",
|
||||
@@ -214,7 +221,8 @@ export const fromWebSocket = (
|
||||
Queue.failCauseUnsafe(
|
||||
messages,
|
||||
Cause.fail(
|
||||
transportError("message", "WebSocket message exceeds the 16 MiB limit", {
|
||||
transportError("WebSocket message exceeds the 16 MiB limit", {
|
||||
body: typeof message === "string" ? message : new TextDecoder().decode(message),
|
||||
url: input.url,
|
||||
operation: "read",
|
||||
code: "message-too-large",
|
||||
@@ -231,7 +239,8 @@ export const fromWebSocket = (
|
||||
Queue.failCauseUnsafe(
|
||||
messages,
|
||||
Cause.fail(
|
||||
transportError("message", "WebSocket inbound queue overflow", {
|
||||
transportError("WebSocket inbound queue overflow", {
|
||||
body: typeof message === "string" ? message : new TextDecoder().decode(message),
|
||||
url: input.url,
|
||||
operation: "read",
|
||||
code: "queue-overflow",
|
||||
@@ -248,7 +257,8 @@ export const fromWebSocket = (
|
||||
Queue.failCauseUnsafe(
|
||||
messages,
|
||||
Cause.fail(
|
||||
transportError("message", "Unsupported WebSocket message payload", {
|
||||
transportError("Unsupported WebSocket message payload", {
|
||||
cause: event,
|
||||
url: input.url,
|
||||
operation: "read",
|
||||
code: "message",
|
||||
@@ -261,7 +271,8 @@ export const fromWebSocket = (
|
||||
Queue.failCauseUnsafe(
|
||||
messages,
|
||||
Cause.fail(
|
||||
transportError("message", `WebSocket error: ${eventMessage(event)}`, {
|
||||
transportError(`WebSocket error: ${eventMessage(event)}`, {
|
||||
cause: "error" in event ? (event.error ?? event) : event,
|
||||
url: input.url,
|
||||
operation: "read",
|
||||
code: "message",
|
||||
@@ -274,7 +285,9 @@ export const fromWebSocket = (
|
||||
Queue.failCauseUnsafe(
|
||||
messages,
|
||||
Cause.fail(
|
||||
transportError("message", `WebSocket closed with code ${event.code}`, {
|
||||
transportError(`WebSocket closed with code ${event.code}`, {
|
||||
body: event.reason,
|
||||
cause: event,
|
||||
url: input.url,
|
||||
operation: "read",
|
||||
code: String(event.code),
|
||||
@@ -298,7 +311,7 @@ export const fromWebSocket = (
|
||||
Effect.suspend(() => {
|
||||
if (ws.readyState !== globalThis.WebSocket.OPEN)
|
||||
return Effect.fail(
|
||||
transportError("sendText", `WebSocket is not open (state ${ws.readyState})`, {
|
||||
transportError(`WebSocket is not open (state ${ws.readyState})`, {
|
||||
url: input.url,
|
||||
operation: "write",
|
||||
phase: "send",
|
||||
@@ -308,7 +321,8 @@ export const fromWebSocket = (
|
||||
return Effect.try({
|
||||
try: () => ws.send(message),
|
||||
catch: (error) =>
|
||||
transportError("sendText", error instanceof Error ? error.message : "Failed to send WebSocket message", {
|
||||
transportError(error instanceof Error ? error.message : "Failed to send WebSocket message", {
|
||||
cause: error,
|
||||
url: input.url,
|
||||
operation: "write",
|
||||
phase: "send",
|
||||
@@ -349,10 +363,23 @@ export const makeDirect = (connector: WebSocketConnector): WebSocketChannelExecu
|
||||
(connection) => connection.close,
|
||||
)
|
||||
const create = yield* exchange.driver.create(undefined)
|
||||
yield* connection.sendText(create.message)
|
||||
yield* connection.sendText(create.message).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new AIError({
|
||||
reason: AIErrorReason.make({
|
||||
...error.reason,
|
||||
message: error.reason.message,
|
||||
cause: error.reason.cause,
|
||||
http: error.reason.http ?? connection.http,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
const decoder = new TextDecoder()
|
||||
let observed = false
|
||||
return {
|
||||
http: connection.http,
|
||||
frames: connection.messages.pipe(
|
||||
Stream.map((message) => {
|
||||
observed = true
|
||||
@@ -364,9 +391,49 @@ export const makeDirect = (connector: WebSocketConnector): WebSocketChannelExecu
|
||||
delivery: observed ? "accepted" : "ambiguous",
|
||||
}),
|
||||
),
|
||||
Stream.mapEffect((frame) => exchange.driver.observe(create, frame)),
|
||||
Stream.mapEffect((frame) =>
|
||||
exchange.driver.observe(create, frame).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new AIError({
|
||||
reason: AIErrorReason.make({
|
||||
...error.reason,
|
||||
message: error.reason.message,
|
||||
cause: error.reason.cause,
|
||||
body: frame,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
Effect.map((observation) =>
|
||||
"error" in observation
|
||||
? {
|
||||
...observation,
|
||||
error: new AIError({
|
||||
reason: AIErrorReason.make({
|
||||
...observation.error.reason,
|
||||
message: observation.error.reason.message,
|
||||
cause: observation.error.reason.cause,
|
||||
body: frame,
|
||||
}),
|
||||
}),
|
||||
}
|
||||
: observation,
|
||||
),
|
||||
),
|
||||
),
|
||||
Stream.takeUntil(observationTerminal),
|
||||
Stream.mapEffect(observationFrame),
|
||||
Stream.mapError(
|
||||
(error) =>
|
||||
new AIError({
|
||||
reason: AIErrorReason.make({
|
||||
...error.reason,
|
||||
message: error.reason.message,
|
||||
cause: error.reason.cause,
|
||||
http: error.reason.http ?? connection.http,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
),
|
||||
complete: Effect.void,
|
||||
}
|
||||
@@ -417,7 +484,7 @@ export const json = <Body, Message>(input: JsonInput<Body, Message>): JsonTransp
|
||||
const webSocket = options?.webSocket
|
||||
if (!webSocket) {
|
||||
return Effect.fail(
|
||||
transportError("json", "WebSocket JSON transport requires StreamOptions.webSocket", {
|
||||
transportError("WebSocket JSON transport requires StreamOptions.webSocket", {
|
||||
url: prepared.url,
|
||||
operation: "request",
|
||||
code: "unavailable",
|
||||
@@ -435,7 +502,7 @@ export const json = <Body, Message>(input: JsonInput<Body, Message>): JsonTransp
|
||||
connect: { url: prepared.url, headers: prepared.headers },
|
||||
fallback: () =>
|
||||
Stream.fail(
|
||||
transportError("fallback", "WebSocket JSON transport does not provide HTTP fallback", {
|
||||
transportError("WebSocket JSON transport does not provide HTTP fallback", {
|
||||
url: prepared.url,
|
||||
operation: "request",
|
||||
code: "websocket",
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
import { Schema } from "effect"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { ModelID, ProviderID, RouteID } from "./ids.js"
|
||||
import { ProviderMetadata } from "./messages.js"
|
||||
|
||||
export const ProviderFailureClassification = Schema.Literals(["context-overflow", "payload-too-large"])
|
||||
export type ProviderFailureClassification = typeof ProviderFailureClassification.Type
|
||||
|
||||
export class HttpRequestDetails extends Schema.Class<HttpRequestDetails>("AI.HttpRequestDetails")({
|
||||
method: Schema.String,
|
||||
export class HttpContext extends Schema.Class<HttpContext>("AI.HttpContext")({
|
||||
url: Schema.String,
|
||||
headers: Schema.Record(Schema.String, Schema.String),
|
||||
}) {}
|
||||
|
||||
export class HttpResponseDetails extends Schema.Class<HttpResponseDetails>("AI.HttpResponseDetails")({
|
||||
status: Schema.Number,
|
||||
status: Schema.Int.check(Schema.isBetween({ minimum: 100, maximum: 599 })),
|
||||
headers: Schema.Record(Schema.String, Schema.String),
|
||||
}) {}
|
||||
|
||||
@@ -24,73 +18,61 @@ export class HttpRateLimitDetails extends Schema.Class<HttpRateLimitDetails>("AI
|
||||
reset: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
}) {}
|
||||
|
||||
export class HttpContext extends Schema.Class<HttpContext>("AI.HttpContext")({
|
||||
request: HttpRequestDetails,
|
||||
response: Schema.optional(HttpResponseDetails),
|
||||
body: Schema.optional(Schema.String),
|
||||
bodyTruncated: Schema.optional(Schema.Boolean),
|
||||
rateLimit: Schema.optional(HttpRateLimitDetails),
|
||||
}) {}
|
||||
|
||||
export class InvalidRequestReason extends Schema.Class<InvalidRequestReason>("AI.Error.InvalidRequest")({
|
||||
_tag: Schema.tag("InvalidRequest"),
|
||||
const ReasonFields = {
|
||||
message: Schema.String,
|
||||
parameter: Schema.optional(Schema.String),
|
||||
classification: Schema.optional(ProviderFailureClassification),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
// Preserve the complete original response or triggering event before decoding narrows it.
|
||||
body: Schema.optional(Schema.String),
|
||||
http: Schema.optional(HttpContext),
|
||||
}) {}
|
||||
cause: Schema.optional(Schema.Defect({ includeStack: true })),
|
||||
}
|
||||
|
||||
export class NoRouteReason extends Schema.Class<NoRouteReason>("AI.Error.NoRoute")({
|
||||
_tag: Schema.tag("NoRoute"),
|
||||
export class InvalidRequestError extends Schema.TaggedError<InvalidRequestError>("AI.Error.InvalidRequest")(
|
||||
"InvalidRequest",
|
||||
{
|
||||
...ReasonFields,
|
||||
parameter: Schema.optional(Schema.String),
|
||||
classification: Schema.optional(ProviderFailureClassification),
|
||||
},
|
||||
) {}
|
||||
|
||||
export class NoRouteError extends Schema.TaggedError<NoRouteError>("AI.Error.NoRoute")("NoRoute", {
|
||||
...ReasonFields,
|
||||
route: RouteID,
|
||||
provider: ProviderID,
|
||||
model: ModelID,
|
||||
}) {
|
||||
get message() {
|
||||
return `No AI route for ${this.provider}/${this.model} using ${this.route}`
|
||||
}
|
||||
}
|
||||
|
||||
export class AuthenticationReason extends Schema.Class<AuthenticationReason>("AI.Error.Authentication")({
|
||||
_tag: Schema.tag("Authentication"),
|
||||
message: Schema.String,
|
||||
kind: Schema.Literals(["missing", "invalid", "expired", "insufficient-permissions", "unknown"]),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
http: Schema.optional(HttpContext),
|
||||
}) {}
|
||||
|
||||
export class RateLimitReason extends Schema.Class<RateLimitReason>("AI.Error.RateLimit")({
|
||||
_tag: Schema.tag("RateLimit"),
|
||||
message: Schema.String,
|
||||
export class AuthenticationError extends Schema.TaggedError<AuthenticationError>("AI.Error.Authentication")(
|
||||
"Authentication",
|
||||
{
|
||||
...ReasonFields,
|
||||
kind: Schema.Literals(["missing", "invalid", "expired", "insufficient-permissions", "unknown"]),
|
||||
},
|
||||
) {}
|
||||
|
||||
export class RateLimitError extends Schema.TaggedError<RateLimitError>("AI.Error.RateLimit")("RateLimit", {
|
||||
...ReasonFields,
|
||||
retryAfterMs: Schema.optional(Schema.Number),
|
||||
rateLimit: Schema.optional(HttpRateLimitDetails),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
http: Schema.optional(HttpContext),
|
||||
}) {}
|
||||
|
||||
export class QuotaExceededReason extends Schema.Class<QuotaExceededReason>("AI.Error.QuotaExceeded")({
|
||||
_tag: Schema.tag("QuotaExceeded"),
|
||||
message: Schema.String,
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
http: Schema.optional(HttpContext),
|
||||
}) {}
|
||||
export class QuotaExceededError extends Schema.TaggedError<QuotaExceededError>("AI.Error.QuotaExceeded")(
|
||||
"QuotaExceeded",
|
||||
ReasonFields,
|
||||
) {}
|
||||
|
||||
export class ContentPolicyReason extends Schema.Class<ContentPolicyReason>("AI.Error.ContentPolicy")({
|
||||
_tag: Schema.tag("ContentPolicy"),
|
||||
message: Schema.String,
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
http: Schema.optional(HttpContext),
|
||||
}) {}
|
||||
export class ContentPolicyError extends Schema.TaggedError<ContentPolicyError>("AI.Error.ContentPolicy")(
|
||||
"ContentPolicy",
|
||||
ReasonFields,
|
||||
) {}
|
||||
|
||||
export class ProviderInternalReason extends Schema.Class<ProviderInternalReason>("AI.Error.ProviderInternal")({
|
||||
_tag: Schema.tag("ProviderInternal"),
|
||||
message: Schema.String,
|
||||
status: Schema.optional(Schema.Number),
|
||||
retryAfterMs: Schema.optional(Schema.Number),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
http: Schema.optional(HttpContext),
|
||||
}) {}
|
||||
export class ProviderInternalError extends Schema.TaggedError<ProviderInternalError>("AI.Error.ProviderInternal")(
|
||||
"ProviderInternal",
|
||||
{
|
||||
...ReasonFields,
|
||||
retryAfterMs: Schema.optional(Schema.Number),
|
||||
},
|
||||
) {}
|
||||
|
||||
export const TransportType = Schema.Literals(["http", "websocket"])
|
||||
export type TransportType = typeof TransportType.Type
|
||||
@@ -98,14 +80,12 @@ export type TransportType = typeof TransportType.Type
|
||||
export const TransportOperation = Schema.Literals(["request", "read", "write"])
|
||||
export type TransportOperation = typeof TransportOperation.Type
|
||||
|
||||
export class TransportReason extends Schema.Class<TransportReason>("AI.Error.Transport")({
|
||||
_tag: Schema.tag("Transport"),
|
||||
message: Schema.String,
|
||||
export class TransportError extends Schema.TaggedError<TransportError>("AI.Error.Transport")("Transport", {
|
||||
...ReasonFields,
|
||||
transport: TransportType,
|
||||
operation: TransportOperation,
|
||||
code: Schema.optional(Schema.String),
|
||||
url: Schema.optional(Schema.String),
|
||||
http: Schema.optional(HttpContext),
|
||||
phase: Schema.optional(
|
||||
Schema.Literals(["prepare", "queue", "connect", "send", "receive", "decode", "complete", "fallback", "close"]),
|
||||
),
|
||||
@@ -115,51 +95,40 @@ export class TransportReason extends Schema.Class<TransportReason>("AI.Error.Tra
|
||||
),
|
||||
}) {}
|
||||
|
||||
export class InvalidProviderOutputReason extends Schema.Class<InvalidProviderOutputReason>(
|
||||
export class InvalidProviderOutputError extends Schema.TaggedError<InvalidProviderOutputError>(
|
||||
"AI.Error.InvalidProviderOutput",
|
||||
)({
|
||||
_tag: Schema.tag("InvalidProviderOutput"),
|
||||
message: Schema.String,
|
||||
)("InvalidProviderOutput", {
|
||||
...ReasonFields,
|
||||
classification: Schema.optional(Schema.Literals(["incomplete-stream"])),
|
||||
route: Schema.optional(Schema.String),
|
||||
raw: Schema.optional(Schema.String),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}) {}
|
||||
|
||||
export class UnknownProviderReason extends Schema.Class<UnknownProviderReason>("AI.Error.UnknownProvider")({
|
||||
_tag: Schema.tag("UnknownProvider"),
|
||||
message: Schema.String,
|
||||
status: Schema.optional(Schema.Number),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
http: Schema.optional(HttpContext),
|
||||
}) {}
|
||||
export class UnknownProviderError extends Schema.TaggedError<UnknownProviderError>("AI.Error.UnknownProvider")(
|
||||
"UnknownProvider",
|
||||
ReasonFields,
|
||||
) {}
|
||||
|
||||
export const AIErrorReason = Schema.Union([
|
||||
InvalidRequestReason,
|
||||
NoRouteReason,
|
||||
AuthenticationReason,
|
||||
RateLimitReason,
|
||||
QuotaExceededReason,
|
||||
ContentPolicyReason,
|
||||
ProviderInternalReason,
|
||||
TransportReason,
|
||||
InvalidProviderOutputReason,
|
||||
UnknownProviderReason,
|
||||
InvalidRequestError,
|
||||
NoRouteError,
|
||||
AuthenticationError,
|
||||
RateLimitError,
|
||||
QuotaExceededError,
|
||||
ContentPolicyError,
|
||||
ProviderInternalError,
|
||||
TransportError,
|
||||
InvalidProviderOutputError,
|
||||
UnknownProviderError,
|
||||
]).pipe(Schema.toTaggedUnion("_tag"))
|
||||
export type AIErrorReason = Schema.Schema.Type<typeof AIErrorReason>
|
||||
|
||||
export class AIError extends Schema.TaggedError<AIError>()("AI.Error", {
|
||||
module: Schema.String,
|
||||
method: Schema.String,
|
||||
reason: AIErrorReason,
|
||||
// Raw provider payload as a string, so classified failures never lose the
|
||||
// original error detail even when the pretty message is a summary.
|
||||
body: Schema.optional(Schema.String),
|
||||
}) {
|
||||
override readonly cause = this.reason
|
||||
|
||||
override get message() {
|
||||
return `${this.module}.${this.method}: ${this.reason.message}`
|
||||
override get message(): string {
|
||||
return this.reason.message
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, Layer, Ref, Stream } from "effect"
|
||||
import { Headers, HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { LLM, AIError } from "../src/index.js"
|
||||
import { LLM, AIError, HttpContext, InvalidProviderOutputError, TransportError } from "../src/index.js"
|
||||
import { LLMClient, RequestExecutor, WebSocketTransport, type WebSocketChannelExecutor } from "../src/route.js"
|
||||
import * as OpenAIChat from "../src/protocols/openai-chat.js"
|
||||
import * as OpenAI from "../src/providers/openai.js"
|
||||
@@ -62,22 +62,85 @@ const countedResponsesLayer = (attempts: Ref.Ref<number>, responses: ReadonlyArr
|
||||
const expectAIError = (error: unknown) => {
|
||||
expect(error).toBeInstanceOf(AIError)
|
||||
if (!(error instanceof AIError)) throw new Error("expected AIError")
|
||||
expect(error.reason).toBeInstanceOf(Error)
|
||||
expect(error.cause).toBe(error.reason)
|
||||
return error
|
||||
}
|
||||
|
||||
const errorHttp = (error: AIError) => ("http" in error.reason ? error.reason.http : undefined)
|
||||
const largeProviderMessage = `Upstream request failed: ${"validation failed; ".repeat(1_000)}`
|
||||
|
||||
describe("RequestExecutor", () => {
|
||||
it.effect("preserves externally captured HTTP errors without inventing response context", () =>
|
||||
Effect.sync(() => {
|
||||
const cause = new Error("upstream request failed")
|
||||
const body = '{"error":{"message":"Rate limited","trace":"original"}}'
|
||||
const error = RequestExecutor.httpFailure({
|
||||
message: "Rate limited",
|
||||
url: request.url,
|
||||
status: 429,
|
||||
responseHeaders: { "Retry-After": "2", "X-Request-ID": "req_external" },
|
||||
responseBody: body,
|
||||
cause,
|
||||
})
|
||||
|
||||
expect(error.message).toBe("Rate limited")
|
||||
expect(error.reason).toMatchObject({ _tag: "RateLimit", retryAfterMs: 2000 })
|
||||
expect(error.reason.body).toBe(body)
|
||||
expect(error.reason.cause).toBe(cause)
|
||||
expect(error.reason.http).toEqual(
|
||||
new HttpContext({
|
||||
url: request.url,
|
||||
status: 429,
|
||||
headers: { "retry-after": "2", "x-request-id": "req_external" },
|
||||
}),
|
||||
)
|
||||
expect(RequestExecutor.httpFailure({ message: "No response", url: request.url }).reason.http).toBeUndefined()
|
||||
expect(RequestExecutor.httpFailure({ message: "No URL", status: 500 }).reason.http).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retains the original body-read failure on an HTTP status error", () =>
|
||||
Effect.gen(function* () {
|
||||
const cause = new Error("response body disconnected")
|
||||
const error = yield* Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
return yield* executor.execute(request).pipe(Effect.flip)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.error(cause)
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 503,
|
||||
headers: { "x-request-id": "req_failed_body" },
|
||||
},
|
||||
),
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
expect(error.reason._tag).toBe("ProviderInternal")
|
||||
expect(error.reason.cause).toBe(cause)
|
||||
expect(error.reason.body).toBeUndefined()
|
||||
expect(error.reason.http).toMatchObject({ status: 503, headers: { "x-request-id": "req_failed_body" } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses response body failures at the executor seam", () =>
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const error = yield* RequestExecutor.stream(executor, secretRequest).pipe(Stream.runDrain, Effect.flip)
|
||||
|
||||
expectAIError(error)
|
||||
expect(error.message).toBe("ECONNRESET: disconnected query-secret-123 header-secret-456")
|
||||
expect(error.reason.http).toMatchObject({ status: 200, url: secretRequest.url })
|
||||
expect(error.reason.cause).toMatchObject({ code: "ECONNRESET" })
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "Transport",
|
||||
message: "ECONNRESET: disconnected query-secret-123 header-secret-456",
|
||||
transport: "http",
|
||||
operation: "read",
|
||||
code: "ECONNRESET",
|
||||
@@ -104,9 +167,10 @@ describe("RequestExecutor", () => {
|
||||
const error = yield* RequestExecutor.stream(executor, secretRequest).pipe(Stream.runDrain, Effect.flip)
|
||||
|
||||
expectAIError(error)
|
||||
expect(error.message).toBe("ECONNRESET: socket closed")
|
||||
expect(error.reason.cause).toBeInstanceOf(TypeError)
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "Transport",
|
||||
message: "ECONNRESET: socket closed",
|
||||
operation: "read",
|
||||
code: "ECONNRESET",
|
||||
})
|
||||
@@ -133,7 +197,9 @@ describe("RequestExecutor", () => {
|
||||
.pipe(Effect.flip)
|
||||
|
||||
expectAIError(error)
|
||||
expect(error.reason.message).toBe("plugin rejected request")
|
||||
expect(error.message).toBe("plugin rejected request")
|
||||
expect(error.reason.cause).toBeInstanceOf(Error)
|
||||
expect(error.reason.http).toBeUndefined()
|
||||
}).pipe(Effect.provide(responsesLayer([]))),
|
||||
)
|
||||
|
||||
@@ -152,16 +218,11 @@ describe("RequestExecutor", () => {
|
||||
.pipe(Effect.flip)
|
||||
|
||||
expectAIError(error)
|
||||
expect(error.message).toBe("ECONNRESET: proxy disconnected proxy-secret")
|
||||
expect(error.reason.http).toBeUndefined()
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "Transport",
|
||||
message: "ECONNRESET: proxy disconnected proxy-secret",
|
||||
url: "https://proxy.test/v1/chat?api_key=proxy-secret",
|
||||
http: {
|
||||
request: {
|
||||
url: "https://proxy.test/v1/chat?api_key=proxy-secret",
|
||||
headers: { authorization: "Bearer proxy-secret" },
|
||||
},
|
||||
},
|
||||
})
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
@@ -206,8 +267,8 @@ describe("RequestExecutor", () => {
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidRequest",
|
||||
classification: "payload-too-large",
|
||||
http: { response: { status: 413 } },
|
||||
})
|
||||
expect(error.reason.http?.status).toBe(413)
|
||||
}).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))),
|
||||
)
|
||||
|
||||
@@ -220,8 +281,8 @@ describe("RequestExecutor", () => {
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidRequest",
|
||||
classification: "context-overflow",
|
||||
http: { response: { status: 413 } },
|
||||
})
|
||||
expect(error.reason.http?.status).toBe(413)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
@@ -241,7 +302,7 @@ describe("RequestExecutor", () => {
|
||||
expectAIError(error)
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
||||
expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
|
||||
expect(error.reason.message).toBe("Provider request failed with HTTP 400")
|
||||
expect(error.message).toBe("Provider request failed with HTTP 400")
|
||||
}).pipe(Effect.provide(responsesLayer([new Response("invalid parameter", { status: 400 })]))),
|
||||
)
|
||||
|
||||
@@ -251,9 +312,9 @@ describe("RequestExecutor", () => {
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectAIError(error)
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", message: largeProviderMessage })
|
||||
expect(errorHttp(error)?.body).toContain(largeProviderMessage)
|
||||
expect(errorHttp(error)?.bodyTruncated).toBeUndefined()
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
||||
expect(error.message).toBe(largeProviderMessage)
|
||||
expect(error.reason.body).toContain(largeProviderMessage)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
@@ -277,8 +338,8 @@ describe("RequestExecutor", () => {
|
||||
expectAIError(error)
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidRequest",
|
||||
message: "Provider request failed with HTTP 400",
|
||||
})
|
||||
expect(error.message).toBe("Provider request failed with HTTP 400")
|
||||
}).pipe(Effect.provide(responsesLayer([new Response('{"error":{"message":" "}}', { status: 400 })]))),
|
||||
)
|
||||
|
||||
@@ -321,29 +382,21 @@ describe("RequestExecutor", () => {
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectAIError(error)
|
||||
expect(error).toMatchObject({
|
||||
reason: {
|
||||
_tag: "RateLimit",
|
||||
retryAfterMs: 0,
|
||||
rateLimit: { retryAfterMs: 0 },
|
||||
http: {
|
||||
request: {
|
||||
method: "POST",
|
||||
url: "https://provider.test/v1/chat?api_key=secret&key=secret&debug=1",
|
||||
headers: { authorization: "Bearer secret", "x-safe": "visible" },
|
||||
},
|
||||
response: {
|
||||
status: 429,
|
||||
headers: {
|
||||
"retry-after-ms": "0",
|
||||
"x-request-id": "req_123",
|
||||
"x-api-key": "secret",
|
||||
},
|
||||
},
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "RateLimit",
|
||||
retryAfterMs: 0,
|
||||
rateLimit: { retryAfterMs: 0 },
|
||||
http: {
|
||||
url: "https://provider.test/v1/chat?api_key=secret&key=secret&debug=1",
|
||||
status: 429,
|
||||
headers: {
|
||||
"retry-after-ms": "0",
|
||||
"x-request-id": "req_123",
|
||||
"x-api-key": "secret",
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(errorHttp(error)?.body).toBe("rate limited")
|
||||
expect(error.reason.body).toBe("rate limited")
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
@@ -362,8 +415,7 @@ describe("RequestExecutor", () => {
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectAIError(error)
|
||||
expect(errorHttp(error)?.request.headers["x-safe"]).toBe("visible")
|
||||
expect(errorHttp(error)?.response?.headers["x-safe"]).toBe("response-secret")
|
||||
expect(error.reason.http?.headers["x-safe"]).toBe("response-secret")
|
||||
}).pipe(
|
||||
Effect.provide(responsesLayer([new Response("bad", { status: 400, headers: { "x-safe": "response-secret" } })])),
|
||||
Effect.provideService(Headers.CurrentRedactedNames, ["x-safe"]),
|
||||
@@ -409,8 +461,8 @@ describe("RequestExecutor", () => {
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectAIError(error)
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
|
||||
expect(errorHttp(error)?.rateLimit).toEqual({
|
||||
expect(error.reason).toMatchObject({ _tag: "RateLimit" })
|
||||
expect(error.reason._tag === "RateLimit" ? error.reason.rateLimit : undefined).toEqual({
|
||||
retryAfterMs: 0,
|
||||
limit: { requests: "100", "input-tokens": "10000" },
|
||||
remaining: { requests: "12", "input-tokens": "9000" },
|
||||
@@ -419,8 +471,8 @@ describe("RequestExecutor", () => {
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
new Response("overloaded", {
|
||||
status: 529,
|
||||
new Response("rate limited", {
|
||||
status: 429,
|
||||
headers: {
|
||||
"retry-after-ms": "0",
|
||||
"anthropic-ratelimit-requests-limit": "100",
|
||||
@@ -452,7 +504,8 @@ describe("RequestExecutor", () => {
|
||||
)
|
||||
|
||||
expectAIError(error)
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status: 503 })
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
|
||||
expect(error.reason.http?.status).toBe(503)
|
||||
expect(yield* Ref.get(attempts)).toBe(1)
|
||||
}),
|
||||
)
|
||||
@@ -465,7 +518,8 @@ describe("RequestExecutor", () => {
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectAIError(error)
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status })
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
|
||||
expect(error.reason.http?.status).toBe(status)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
@@ -489,8 +543,7 @@ describe("RequestExecutor", () => {
|
||||
|
||||
expectAIError(error)
|
||||
expect(error.reason).toMatchObject({ _tag: "Authentication" })
|
||||
expect(errorHttp(error)?.bodyTruncated).toBeUndefined()
|
||||
expect(errorHttp(error)?.body).toHaveLength(20_000)
|
||||
expect(error.reason.body).toHaveLength(20_000)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
@@ -507,9 +560,7 @@ describe("RequestExecutor", () => {
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectAIError(error)
|
||||
expect(errorHttp(error)?.body).toBe(
|
||||
'{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}',
|
||||
)
|
||||
expect(error.reason.body).toBe('{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}')
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
@@ -527,7 +578,7 @@ describe("RequestExecutor", () => {
|
||||
const error = yield* executor.execute(secretRequest).pipe(Effect.flip)
|
||||
|
||||
expectAIError(error)
|
||||
expect(errorHttp(error)?.body).toBe("provider echoed query-secret-123 and authorization header-secret-456")
|
||||
expect(error.reason.body).toBe("provider echoed query-secret-123 and authorization header-secret-456")
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
@@ -564,6 +615,9 @@ describe("RequestExecutor", () => {
|
||||
|
||||
expectAIError(error)
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
|
||||
expect(error.reason.body).toBe("not-json")
|
||||
expect(error.reason.cause).toBeDefined()
|
||||
expect(error.reason.http).toMatchObject({ status: 200, headers: { "content-type": "text/event-stream" } })
|
||||
expect(yield* Ref.get(attempts)).toBe(1)
|
||||
}),
|
||||
)
|
||||
@@ -577,6 +631,154 @@ describe("WebSocket channel execution", () => {
|
||||
JSON.stringify({ type: "response.completed", response: { id: "resp_1" } }),
|
||||
]
|
||||
|
||||
it.effect("preserves close reasons and native event causes without fabricated HTTP metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
class TestSocket extends EventTarget {
|
||||
readyState = globalThis.WebSocket.OPEN
|
||||
send() {}
|
||||
close() {}
|
||||
}
|
||||
const socket = new TestSocket()
|
||||
const connection = yield* WebSocketTransport.fromWebSocket(
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||
socket as unknown as globalThis.WebSocket,
|
||||
{ url: "wss://provider.test/responses", headers: Headers.empty },
|
||||
)
|
||||
const event = new CloseEvent("close", { code: 1011, reason: "upstream trace: req_close" })
|
||||
socket.dispatchEvent(event)
|
||||
const error = yield* connection.messages.pipe(Stream.runDrain, Effect.flip)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "Transport", code: "1011", phase: "close" })
|
||||
expect(error.message).toBe("WebSocket closed with code 1011")
|
||||
expect(error.reason.body).toBe(event.reason)
|
||||
expect(error.reason.cause).toBe(event)
|
||||
expect(error.reason.http).toBeUndefined()
|
||||
yield* connection.close
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves opening event errors and native send exceptions", () =>
|
||||
Effect.gen(function* () {
|
||||
const cause = new Error("native send failed")
|
||||
class TestSocket extends EventTarget {
|
||||
readyState = globalThis.WebSocket.CONNECTING
|
||||
send() {
|
||||
throw cause
|
||||
}
|
||||
close() {}
|
||||
}
|
||||
const socket = new TestSocket()
|
||||
const open = WebSocketTransport.fromWebSocket(
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||
socket as unknown as globalThis.WebSocket,
|
||||
{ url: "wss://provider.test/responses", headers: Headers.empty },
|
||||
)
|
||||
const fiber = yield* open.pipe(Effect.flip, Effect.forkChild({ startImmediately: true }))
|
||||
const event = new ErrorEvent("error", { message: "handshake rejected", error: cause })
|
||||
socket.dispatchEvent(event)
|
||||
const error = yield* Fiber.join(fiber)
|
||||
expect(error.reason.cause).toBe(cause)
|
||||
expect(error.message).toContain("handshake rejected")
|
||||
expect(error.reason.http).toBeUndefined()
|
||||
|
||||
socket.readyState = globalThis.WebSocket.OPEN
|
||||
const connection = yield* open
|
||||
const sent = yield* connection.sendText("create").pipe(Effect.flip)
|
||||
expect(sent.reason.cause).toBe(cause)
|
||||
expect(sent.message).toBe(cause.message)
|
||||
yield* connection.close
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves raw driver failures and known upgrade metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
const cause = new Error("driver validation failed")
|
||||
const frame = '{ "error": "failed", "trace": "original" }'
|
||||
const http = new HttpContext({
|
||||
url: "https://provider.test/responses",
|
||||
status: 101,
|
||||
headers: { upgrade: "websocket" },
|
||||
})
|
||||
const executor = WebSocketTransport.makeDirect({
|
||||
open: () =>
|
||||
Effect.succeed({
|
||||
http,
|
||||
sendText: () => Effect.void,
|
||||
messages: Stream.make(frame),
|
||||
close: Effect.void,
|
||||
}),
|
||||
})
|
||||
const execution = yield* executor.execute({
|
||||
id: "exchange_error",
|
||||
connect: { url: "wss://provider.test/responses", headers: Headers.empty },
|
||||
fallback: () => Stream.empty,
|
||||
driver: {
|
||||
create: () => Effect.succeed({ message: "create", mode: "full" }),
|
||||
observe: () =>
|
||||
Effect.succeed({
|
||||
type: "provider-failure",
|
||||
error: new AIError({
|
||||
reason: new InvalidProviderOutputError({
|
||||
message: "Driver failed",
|
||||
cause,
|
||||
body: "narrowed",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
})
|
||||
const error = yield* execution.frames.pipe(Stream.runDrain, Effect.flip)
|
||||
|
||||
expect(error.message).toBe("Driver failed")
|
||||
expect(error.reason.body).toBe(frame)
|
||||
expect(error.reason.cause).toBe(cause)
|
||||
expect(error.reason.http).toBe(http)
|
||||
expect(execution.http).toBe(http)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retains diagnostic fields when annotating transport delivery", () =>
|
||||
Effect.gen(function* () {
|
||||
const cause = new Error("connection closed")
|
||||
const executor = WebSocketTransport.makeDirect({
|
||||
open: () =>
|
||||
Effect.succeed({
|
||||
sendText: () => Effect.void,
|
||||
messages: Stream.fail(
|
||||
new AIError({
|
||||
reason: new TransportError({
|
||||
message: "Socket closed",
|
||||
transport: "websocket",
|
||||
operation: "read",
|
||||
phase: "close",
|
||||
recovery: "retry-full",
|
||||
body: "server close detail",
|
||||
cause,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
close: Effect.void,
|
||||
}),
|
||||
})
|
||||
const execution = yield* executor.execute({
|
||||
id: "exchange_closed",
|
||||
connect: { url: "wss://provider.test/responses", headers: Headers.empty },
|
||||
fallback: () => Stream.empty,
|
||||
driver: {
|
||||
create: () => Effect.succeed({ message: "create", mode: "full" }),
|
||||
observe: (_create, frame) => Effect.succeed({ type: "frame", frame }),
|
||||
},
|
||||
})
|
||||
const error = yield* execution.frames.pipe(Stream.runDrain, Effect.flip)
|
||||
|
||||
expect(error.message).toBe("Socket closed")
|
||||
expect(error.reason.body).toBe("server close detail")
|
||||
expect(error.reason.cause).toBe(cause)
|
||||
expect(error.reason).toMatchObject({ phase: "close", delivery: "ambiguous", recovery: "retry-full" })
|
||||
expect(error.reason.http).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("runs a channel driver through the direct executor", () =>
|
||||
Effect.gen(function* () {
|
||||
const sent = yield* Ref.make("")
|
||||
|
||||
@@ -7,6 +7,37 @@ import { it } from "./lib/effect.js"
|
||||
import { dynamicResponse } from "./lib/http.js"
|
||||
|
||||
describe("Image", () => {
|
||||
for (const provider of [OpenAI, Google, XAI, ZAI]) {
|
||||
const model = provider.configure({ apiKey: "test", baseURL: "https://image.test" }).image("image-model")
|
||||
for (const body of ['{"data":42,"candidates":42,"opaque":{"nested":[1,2]},"trace":"outer"}', '{"invalid":']) {
|
||||
it.effect(`retains ${model.provider} image response body and decode cause: ${body}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* Image.generate({ model, prompt: "hello" }).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.message).toContain("invalid response")
|
||||
expect(error.reason.body).toBe(body)
|
||||
expect(error.reason.http).toMatchObject({ status: 200, headers: { "x-image-trace": "trace-1" } })
|
||||
expect(error.reason.http?.url).toStartWith("https://image.test/")
|
||||
expect(error.reason.cause).toBeInstanceOf(Error)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
ImageClient.layer.pipe(
|
||||
Layer.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.succeed(
|
||||
input.respond(body, {
|
||||
headers: { "content-type": "application/json", "x-image-trace": "trace-1" },
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
it.effect("generates images through the OpenAI Images API", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Image.generate({
|
||||
@@ -530,22 +561,17 @@ describe("Image", () => {
|
||||
Effect.sync(() => {
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
if (error.reason._tag !== "InvalidProviderOutput") return
|
||||
expect(error.reason.message).toContain("finish reasons: IMAGE_SAFETY")
|
||||
expect(error.reason.providerMetadata).toEqual({
|
||||
google: {
|
||||
promptFeedback: { blockReason: "SAFETY" },
|
||||
candidates: [
|
||||
{
|
||||
index: 0,
|
||||
finishReason: "IMAGE_SAFETY",
|
||||
finishMessage: "The generated image was blocked by safety filters.",
|
||||
safetyRatings: [{ category: "HARM_CATEGORY_DANGEROUS_CONTENT", blocked: true }],
|
||||
citationMetadata: undefined,
|
||||
groundingMetadata: undefined,
|
||||
parts: [{ type: "text", text: "blocked", thought: false, thoughtSignature: undefined }],
|
||||
},
|
||||
],
|
||||
},
|
||||
expect(error.message).toContain("finish reasons: IMAGE_SAFETY")
|
||||
expect(JSON.parse(error.reason.body ?? "")).toEqual({
|
||||
promptFeedback: { blockReason: "SAFETY" },
|
||||
candidates: [
|
||||
{
|
||||
finishReason: "IMAGE_SAFETY",
|
||||
finishMessage: "The generated image was blocked by safety filters.",
|
||||
safetyRatings: [{ category: "HARM_CATEGORY_DANGEROUS_CONTENT", blocked: true }],
|
||||
content: { parts: [{ text: "blocked", thought: false }] },
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -111,6 +111,39 @@ describe("provider error classification", () => {
|
||||
})
|
||||
|
||||
describe("provider error rawBody classification", () => {
|
||||
test("classifies provider envelopes without separate code inputs", () => {
|
||||
const cases = [
|
||||
['{"type":"error","error":{"type":"overloaded_error","message":"Try again"}}', "ProviderInternal"],
|
||||
['{"error":{"code":"insufficient_quota","message":"Request failed"}}', "QuotaExceeded"],
|
||||
[
|
||||
'{"type":"response.failed","response":{"error":{"code":"authentication_error","message":"Denied"}}}',
|
||||
"Authentication",
|
||||
],
|
||||
['{"error":{"code":429,"status":"RESOURCE_EXHAUSTED","message":"Try again"}}', "ProviderInternal"],
|
||||
['{"exception":{"type":"throttlingException","details":{"message":"Try again"}}}', "RateLimit"],
|
||||
] as const
|
||||
for (const [rawBody, expected] of cases) {
|
||||
const reason = classifyProviderFailure({ message: "Request failed", rawBody })
|
||||
expect(reason._tag).toBe(expected)
|
||||
expect(reason.body).toBe(rawBody)
|
||||
expect(reason).not.toHaveProperty("code")
|
||||
}
|
||||
})
|
||||
|
||||
test("classifies separately supplied SDK data without replacing the response body", () => {
|
||||
const data = { error: { code: "authentication_error" } }
|
||||
for (const value of [data, JSON.stringify(data)]) {
|
||||
const reason = classifyProviderFailure({
|
||||
message: "Request failed",
|
||||
status: 400,
|
||||
rawBody: '{"message":"Request failed"}',
|
||||
data: value,
|
||||
})
|
||||
expect(reason._tag).toBe("Authentication")
|
||||
expect(reason.body).toBe('{"message":"Request failed"}')
|
||||
}
|
||||
})
|
||||
|
||||
test("classifies overflow signals buried in the raw payload when the summary is vague", () => {
|
||||
const reason = classifyProviderFailure({
|
||||
message: "Request failed",
|
||||
|
||||
@@ -39,7 +39,7 @@ describe("Anthropic Messages sad-path recorded", () => {
|
||||
|
||||
expect(error).toBeInstanceOf(AIError)
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
||||
expect(error.reason.message).toContain("`tool_use` ids were found without `tool_result` blocks")
|
||||
expect(error.message).toContain("`tool_use` ids were found without `tool_result` blocks")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -966,9 +966,8 @@ describe("Anthropic Messages route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidProviderOutput",
|
||||
classification: "incomplete-stream",
|
||||
expect(error).toMatchObject({
|
||||
reason: { _tag: "InvalidProviderOutput", classification: "incomplete-stream" },
|
||||
message: "The provider response ended unexpectedly.",
|
||||
})
|
||||
}),
|
||||
@@ -1057,8 +1056,8 @@ describe("Anthropic Messages route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidProviderOutput",
|
||||
expect(error).toMatchObject({
|
||||
reason: { _tag: "InvalidProviderOutput" },
|
||||
message: "Invalid anthropic/anthropic-messages stream event",
|
||||
})
|
||||
}),
|
||||
@@ -1079,8 +1078,8 @@ describe("Anthropic Messages route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidProviderOutput",
|
||||
expect(error).toMatchObject({
|
||||
reason: { _tag: "InvalidProviderOutput" },
|
||||
message: "Invalid anthropic/anthropic-messages stream event",
|
||||
})
|
||||
}),
|
||||
@@ -1106,8 +1105,8 @@ describe("Anthropic Messages route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidProviderOutput",
|
||||
expect(error).toMatchObject({
|
||||
reason: { _tag: "InvalidProviderOutput" },
|
||||
message: "Invalid anthropic/anthropic-messages stream event",
|
||||
})
|
||||
}),
|
||||
@@ -1122,8 +1121,8 @@ describe("Anthropic Messages route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidProviderOutput",
|
||||
expect(error).toMatchObject({
|
||||
reason: { _tag: "InvalidProviderOutput" },
|
||||
message: "Invalid anthropic/anthropic-messages stream event",
|
||||
})
|
||||
}),
|
||||
@@ -1645,7 +1644,7 @@ describe("Anthropic Messages route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", message: "overloaded_error: Overloaded" })
|
||||
expect(error).toMatchObject({ reason: { _tag: "ProviderInternal" }, message: "overloaded_error: Overloaded" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1663,10 +1662,9 @@ describe("Anthropic Messages route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidRequest",
|
||||
expect(error).toMatchObject({
|
||||
reason: { _tag: "InvalidRequest", classification: "context-overflow" },
|
||||
message: "invalid_request_error: prompt is too long: 210000 tokens",
|
||||
classification: "context-overflow",
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -1678,7 +1676,7 @@ describe("Anthropic Messages route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", message: "overloaded_error" })
|
||||
expect(error).toMatchObject({ reason: { _tag: "ProviderInternal" }, message: "overloaded_error" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1689,7 +1687,7 @@ describe("Anthropic Messages route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "Anthropic Messages stream error" })
|
||||
expect(error).toMatchObject({ reason: { _tag: "UnknownProvider" }, message: "Anthropic Messages stream error" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1706,7 +1704,7 @@ describe("Anthropic Messages route", () => {
|
||||
)
|
||||
|
||||
expect(error).toBeInstanceOf(AIError)
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", message: "Bad request" })
|
||||
expect(error).toMatchObject({ reason: { _tag: "InvalidRequest" }, message: "Bad request" })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -789,19 +789,32 @@ describe("Bedrock Converse route", () => {
|
||||
])
|
||||
const error = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)), Effect.flip)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "A future provider failure" })
|
||||
expect(error).toMatchObject({ reason: { _tag: "UnknownProvider" }, message: "A future provider failure" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies throttlingException as a rate limit", () =>
|
||||
Effect.gen(function* () {
|
||||
const payload = { message: "Slow down", details: { opaque: [1, 2] }, trace: "outer", p: "padding" }
|
||||
const body = concat([
|
||||
eventFrame("messageStart", { role: "assistant" }),
|
||||
exceptionFrame("throttlingException", { message: "Slow down" }),
|
||||
exceptionFrame("throttlingException", payload),
|
||||
])
|
||||
const error = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)), Effect.flip)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "RateLimit", message: "Slow down" })
|
||||
expect(error).toMatchObject({ reason: { _tag: "RateLimit" }, message: "Slow down" })
|
||||
expect(JSON.parse(error.reason.body ?? "")).toEqual({
|
||||
headers: {
|
||||
":message-type": { type: "string", value: "exception" },
|
||||
":exception-type": { type: "string", value: "throttlingException" },
|
||||
":content-type": { type: "string", value: "application/json" },
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
expect(error.reason.http).toMatchObject({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/vnd.amazon.eventstream" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -814,10 +827,9 @@ describe("Bedrock Converse route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidRequest",
|
||||
expect(error).toMatchObject({
|
||||
reason: { _tag: "InvalidRequest", classification: "context-overflow" },
|
||||
message: "Input is too long for requested model",
|
||||
classification: "context-overflow",
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -836,7 +848,7 @@ describe("Bedrock Converse route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", message: "Upstream model failed" })
|
||||
expect(error).toMatchObject({ reason: { _tag: "ProviderInternal" }, message: "Upstream model failed" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -847,10 +859,32 @@ describe("Bedrock Converse route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidProviderOutput",
|
||||
expect(error).toMatchObject({
|
||||
reason: { _tag: "InvalidProviderOutput" },
|
||||
message: "BadStream: Stream failed",
|
||||
})
|
||||
expect(JSON.parse(error.reason.body ?? "")).toMatchObject({
|
||||
headers: { ":error-code": { value: "BadStream" } },
|
||||
body: "",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retains malformed AWS payloads with headers and decode cause", () =>
|
||||
Effect.gen(function* () {
|
||||
const headers = {
|
||||
":message-type": { type: "string" as const, value: "event" },
|
||||
":event-type": { type: "string" as const, value: "messageStart" },
|
||||
}
|
||||
const body = '{"malformed":'
|
||||
const error = yield* LLMClient.generate(baseRequest).pipe(
|
||||
Effect.provide(fixedBytes(codec.encode({ headers, body: utf8Encoder.encode(body) }))),
|
||||
Effect.flip,
|
||||
)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(JSON.parse(error.reason.body ?? "")).toEqual({ headers, body })
|
||||
expect(error.reason.cause).toBeInstanceOf(Error)
|
||||
expect(error.reason.http?.status).toBe(200)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM } from "../../src/index.js"
|
||||
import { Anthropic, Google, OpenAI } from "../../src/providers.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { fixedResponse } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
describe("provider error retention", () => {
|
||||
const options = { apiKey: "test", baseURL: "https://provider.test" }
|
||||
const cases = [
|
||||
{
|
||||
name: "Anthropic",
|
||||
model: Anthropic.configure(options).model("claude"),
|
||||
event: { type: "error", error: { type: "rate_limit_error", message: "Slow down", details: { opaque: [1, 2] } } },
|
||||
},
|
||||
{
|
||||
name: "OpenAI Chat",
|
||||
model: OpenAI.configure(options).chat("gpt"),
|
||||
event: { error: { code: "rate_limit_exceeded", message: "Slow down", details: { opaque: [1, 2] } } },
|
||||
},
|
||||
{
|
||||
name: "OpenAI Responses",
|
||||
model: OpenAI.configure(options).responses("gpt"),
|
||||
event: {
|
||||
type: "response.failed",
|
||||
response: {
|
||||
id: "resp_error",
|
||||
error: { code: "rate_limit_exceeded", message: "Slow down", details: { opaque: [1, 2] } },
|
||||
opaque: { upstream: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Gemini",
|
||||
model: Google.configure(options).model("gemini"),
|
||||
event: { error: { code: 429, status: "RESOURCE_EXHAUSTED", message: "Slow down", details: { opaque: [1, 2] } } },
|
||||
},
|
||||
]
|
||||
|
||||
for (const entry of cases) {
|
||||
it.effect(`retains the complete ${entry.name} event and HTTP context`, () =>
|
||||
Effect.gen(function* () {
|
||||
const body = JSON.stringify({ ...entry.event, trace: { opaque: "outer" } })
|
||||
const error = yield* LLMClient.generate(LLM.request({ model: entry.model, prompt: "hello" })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(sseEvents(body), {
|
||||
headers: { "content-type": "text/event-stream", "x-provider-trace": "trace-1" },
|
||||
}),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
expect(error.message).toContain("Slow down")
|
||||
expect(error.reason._tag).toBe(entry.name === "Gemini" ? "ProviderInternal" : "RateLimit")
|
||||
expect(error.reason.body).toBe(body)
|
||||
expect(error.reason.http).toMatchObject({ status: 200, headers: { "x-provider-trace": "trace-1" } })
|
||||
expect(error.reason.http?.url).toStartWith("https://provider.test/")
|
||||
expect(error.reason.cause).toBeUndefined()
|
||||
expect(error.cause).toBe(error.reason)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("retains malformed provider frames and the original decode cause", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = '{"type":"error","error":{"message":42,"opaque":{"nested":true}},"trace":"outer"}'
|
||||
const error = yield* LLMClient.generate(
|
||||
LLM.request({ model: Anthropic.configure(options).model("claude"), prompt: "hello" }),
|
||||
).pipe(Effect.provide(fixedResponse(sseEvents(body))), Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.reason.body).toBe(body)
|
||||
expect(error.reason.cause).toBeInstanceOf(Error)
|
||||
expect(error.reason.http?.status).toBe(200)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retains the HTTP response context when a channel falls back", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = '{"type":"error","error":{"code":"rate_limit_exceeded","message":"Slow down","extra":42}}'
|
||||
const error = yield* LLMClient.generate(
|
||||
LLM.request({ model: OpenAI.configure(options).responses("gpt"), prompt: "hello" }),
|
||||
{
|
||||
webSocket: {
|
||||
execute: (exchange) => Effect.succeed({ frames: exchange.fallback(), complete: Effect.void }),
|
||||
},
|
||||
},
|
||||
).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(body), { headers: { "x-provider-trace": "fallback-1" } })),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason._tag).toBe("RateLimit")
|
||||
expect(error.reason.body).toBe(body)
|
||||
expect(error.reason.http).toMatchObject({
|
||||
url: "https://provider.test/responses",
|
||||
status: 200,
|
||||
headers: { "x-provider-trace": "fallback-1" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1457,7 +1457,7 @@ describe("OpenAI Chat route", () => {
|
||||
expect(error.message).toContain("OpenAI Chat tool call delta is missing id or name")
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
if (error.reason._tag !== "InvalidProviderOutput") return
|
||||
expect(decodeJson(error.reason.raw ?? "")).toMatchObject({
|
||||
expect(decodeJson(error.reason.body ?? "")).toMatchObject({
|
||||
choices: [{ finish_reason: "tool_calls" }],
|
||||
})
|
||||
}),
|
||||
@@ -1534,9 +1534,9 @@ describe("OpenAI Chat route", () => {
|
||||
)
|
||||
|
||||
expect((yield* Ref.get(events)).some((event) => event.type === "text-delta")).toBeTrue()
|
||||
expect(error.message).toBe("ECONNRESET: socket closed unexpectedly")
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "Transport",
|
||||
message: "ECONNRESET: socket closed unexpectedly",
|
||||
transport: "http",
|
||||
operation: "read",
|
||||
code: "ECONNRESET",
|
||||
@@ -1552,9 +1552,9 @@ describe("OpenAI Chat route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.message).toBe("ECONNRESET: socket closed before output")
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "Transport",
|
||||
message: "ECONNRESET: socket closed before output",
|
||||
transport: "http",
|
||||
operation: "read",
|
||||
code: "ECONNRESET",
|
||||
@@ -1575,7 +1575,7 @@ describe("OpenAI Chat route", () => {
|
||||
)
|
||||
|
||||
expect(error).toBeInstanceOf(AIError)
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", message: "Bad request" })
|
||||
expect(error).toMatchObject({ reason: { _tag: "InvalidRequest" }, message: "Bad request" })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -489,9 +489,8 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidProviderOutput",
|
||||
classification: "incomplete-stream",
|
||||
expect(error).toMatchObject({
|
||||
reason: { _tag: "InvalidProviderOutput", classification: "incomplete-stream" },
|
||||
message: "OpenAI Chat stream ended without finish_reason",
|
||||
})
|
||||
}),
|
||||
@@ -527,11 +526,11 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "ProviderInternal",
|
||||
expect(error).toMatchObject({
|
||||
reason: { _tag: "ProviderInternal" },
|
||||
message: "Provider reported a network error (finish_reason: network_error)",
|
||||
})
|
||||
expect(decodeJson(error.body ?? "")).toMatchObject({
|
||||
expect(decodeJson(error.reason.body ?? "")).toMatchObject({
|
||||
id: "chatcmpl_fixture",
|
||||
choices: [{ finish_reason: "network_error" }],
|
||||
})
|
||||
@@ -540,8 +539,8 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
Effect.provide(fixedResponse(sseEvents(deltaChunk({}, "error")))),
|
||||
Effect.flip,
|
||||
)
|
||||
expect(generic.reason).toMatchObject({
|
||||
_tag: "UnknownProvider",
|
||||
expect(generic).toMatchObject({
|
||||
reason: { _tag: "UnknownProvider" },
|
||||
message: "Provider reported an error (finish_reason: error)",
|
||||
})
|
||||
}),
|
||||
@@ -562,8 +561,8 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", message: "Provider disconnected", status: 502 })
|
||||
expect(decodeJson(error.body ?? "")).toMatchObject({
|
||||
expect(error).toMatchObject({ reason: { _tag: "ProviderInternal" }, message: "Provider disconnected" })
|
||||
expect(decodeJson(error.reason.body ?? "")).toMatchObject({
|
||||
id: "chatcmpl_error",
|
||||
error: { code: 502, message: "Provider disconnected", details: { upstream: "vendor" } },
|
||||
trace_id: "trace_1",
|
||||
@@ -603,7 +602,7 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
expect(error.message).toContain("OpenAI Chat received content after the finish reason")
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
if (error.reason._tag !== "InvalidProviderOutput") return
|
||||
expect(decodeJson(error.reason.raw ?? "")).toMatchObject({
|
||||
expect(decodeJson(error.reason.body ?? "")).toMatchObject({
|
||||
choices: [{ delta: { tool_calls: [{ id: "call_1" }] } }],
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import {
|
||||
LLM,
|
||||
AIError,
|
||||
HttpContext,
|
||||
HttpOptions,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
ToolCallPart,
|
||||
ToolDefinition,
|
||||
ToolResultPart,
|
||||
TransportReason,
|
||||
TransportError,
|
||||
Usage,
|
||||
} from "../../src/index.js"
|
||||
import {
|
||||
@@ -52,10 +53,11 @@ const baseChannelDriver = (message: string): WebSocketChannelDriver => ({
|
||||
return Effect.succeed({
|
||||
type: "provider-failure",
|
||||
error: new AIError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new TransportReason({
|
||||
reason: new TransportError({
|
||||
message: "provider rejected request",
|
||||
body: frame,
|
||||
cause: new Error("provider cause"),
|
||||
http: new HttpContext({ url: "https://provider.test", status: 200, headers: { "x-trace": "trace-1" } }),
|
||||
transport: "websocket",
|
||||
operation: "read",
|
||||
phase: "receive",
|
||||
@@ -744,6 +746,18 @@ describe("OpenAI Responses route", () => {
|
||||
reason: { _tag: "Transport", delivery: "rejected", recovery: "rotate-and-retry-full" },
|
||||
},
|
||||
})
|
||||
for (const observation of [missing, limit]) {
|
||||
expect(observation.type).toBe("rejected")
|
||||
if (observation.type !== "rejected") continue
|
||||
expect(observation.error.message).toBe("provider rejected request")
|
||||
expect(observation.error.reason.cause).toBeInstanceOf(Error)
|
||||
expect(observation.error.reason.cause).toMatchObject({ message: "provider cause" })
|
||||
expect(observation.error.reason.http).toMatchObject({ status: 200, headers: { "x-trace": "trace-1" } })
|
||||
expect(ProviderShared.decodeJson(observation.error.reason.body ?? "")).toMatchObject({
|
||||
type: "error",
|
||||
error: { code: expect.any(String) },
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1110,9 +1124,7 @@ describe("OpenAI Responses route", () => {
|
||||
it.effect("marks post-send WebSocket failures with delivery state", () =>
|
||||
Effect.gen(function* () {
|
||||
const failure = new AIError({
|
||||
module: "test",
|
||||
method: "receive",
|
||||
reason: new TransportReason({
|
||||
reason: new TransportError({
|
||||
message: "socket closed",
|
||||
transport: "websocket",
|
||||
operation: "read",
|
||||
@@ -4435,7 +4447,7 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
expect(error).toBeInstanceOf(AIError)
|
||||
expect(error.reason).toMatchObject({ _tag: "RateLimit", message: "rate_limit_exceeded: Slow down" })
|
||||
expect(error).toMatchObject({ reason: { _tag: "RateLimit" }, message: "rate_limit_exceeded: Slow down" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -4446,7 +4458,7 @@ describe("OpenAI Responses route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", message: "internal_error" })
|
||||
expect(error).toMatchObject({ reason: { _tag: "ProviderInternal" }, message: "internal_error" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -4457,7 +4469,7 @@ describe("OpenAI Responses route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", message: "internal_error" })
|
||||
expect(error).toMatchObject({ reason: { _tag: "ProviderInternal" }, message: "internal_error" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -4482,8 +4494,8 @@ describe("OpenAI Responses route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "ProviderInternal",
|
||||
expect(error).toMatchObject({
|
||||
reason: { _tag: "ProviderInternal" },
|
||||
message: "server_error: Upstream model unavailable",
|
||||
})
|
||||
}),
|
||||
@@ -4503,7 +4515,7 @@ describe("OpenAI Responses route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", message: "invalid_prompt" })
|
||||
expect(error).toMatchObject({ reason: { _tag: "InvalidRequest" }, message: "invalid_prompt" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -4526,10 +4538,9 @@ describe("OpenAI Responses route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidRequest",
|
||||
expect(error).toMatchObject({
|
||||
reason: { _tag: "InvalidRequest", classification: "context-overflow" },
|
||||
message: "context_length_exceeded: prompt too long",
|
||||
classification: "context-overflow",
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -4554,10 +4565,9 @@ describe("OpenAI Responses route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidRequest",
|
||||
expect(error).toMatchObject({
|
||||
reason: { _tag: "InvalidRequest", classification: "context-overflow" },
|
||||
message: "context_length_exceeded: prompt too long",
|
||||
classification: "context-overflow",
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -4579,7 +4589,7 @@ describe("OpenAI Responses route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "Something went wrong" })
|
||||
expect(error).toMatchObject({ reason: { _tag: "UnknownProvider" }, message: "Something went wrong" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -4591,8 +4601,8 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "UnknownProvider" })
|
||||
expect(error.reason.message).toContain('"error":null')
|
||||
expect(error.body).toBe(error.reason.message)
|
||||
expect(error.message).toContain('"error":null')
|
||||
expect(error.reason.body).toBe(error.message)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -4604,8 +4614,8 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
|
||||
expect(error.reason.message).toContain('"type":"error"')
|
||||
expect(error.body).toBe(error.reason.message)
|
||||
expect(error.message).toContain('"type":"error"')
|
||||
expect(error.reason.body).toBe(error.message)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -4617,8 +4627,8 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "UnknownProvider" })
|
||||
expect(error.reason.message).toContain('"resp_failed_3"')
|
||||
expect(error.body).toBe(error.reason.message)
|
||||
expect(error.message).toContain('"resp_failed_3"')
|
||||
expect(error.reason.body).toBe(error.message)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -4635,7 +4645,7 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
expect(error).toBeInstanceOf(AIError)
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", message: "Bad request" })
|
||||
expect(error).toMatchObject({ reason: { _tag: "InvalidRequest" }, message: "Bad request" })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import * as OpenAIChat from "../src/protocols/openai-chat.js"
|
||||
import { Auth } from "../src/route.js"
|
||||
import { AIError, HttpContext, InvalidProviderOutputError, LLM } from "../src/index.js"
|
||||
import { Anthropic } from "../src/providers.js"
|
||||
import { Auth, Framing, HttpTransport, LLMClient, Route } from "../src/route.js"
|
||||
import { fixedResponse, truncatedStream } from "./lib/http.js"
|
||||
import { sseEvents } from "./lib/sse.js"
|
||||
import { it } from "./lib/effect.js"
|
||||
|
||||
describe("Route.with", () => {
|
||||
test("merges endpoint query and header defaults while replacing auth and id", () => {
|
||||
@@ -50,3 +56,175 @@ describe("Route.with", () => {
|
||||
expect(overridden.defaults).not.toHaveProperty("providerMetadataKey")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Route diagnostics", () => {
|
||||
const route = OpenAIChat.route.with({ endpoint: { baseURL: "https://provider.test/v1" } })
|
||||
const request = LLM.request({ model: route.model({ id: "test" }), prompt: "Hello" })
|
||||
const headers = { "content-type": "text/event-stream", "x-request-id": "req_stream" }
|
||||
|
||||
it.effect("retains an entire invalid event and its validation cause", () =>
|
||||
Effect.gen(function* () {
|
||||
const frame = '{ "choices": "invalid", "diagnostic": { "detail": "original" } }'
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(frame), { headers })),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.reason.body).toBe(frame)
|
||||
expect(error.reason.cause).toMatchObject({ _tag: "SchemaError" })
|
||||
expect(error.reason.http).toEqual(
|
||||
new HttpContext({ url: "https://provider.test/v1/chat/completions", status: 200, headers }),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retains original provider error fields discarded by the event schema", () =>
|
||||
Effect.gen(function* () {
|
||||
const frame =
|
||||
'{ "error": { "message": "Rate limit exceeded", "code": "rate_limit_exceeded", "debug": { "trace": "original" } }, "request_id": "req_original" }'
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(frame), { headers })),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason._tag).toBe("RateLimit")
|
||||
expect(error.message).toBe("Rate limit exceeded")
|
||||
expect(error.reason.body).toBe(frame)
|
||||
expect(error.reason.http?.headers["x-request-id"]).toBe("req_stream")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves semantic parser bodies while replacing serialized event fallbacks", () =>
|
||||
Effect.forEach([undefined, '{"query": BROKEN}', ""], (body) =>
|
||||
Effect.gen(function* () {
|
||||
const cause = new Error("parser failure")
|
||||
const http = new HttpContext({
|
||||
url: "https://upstream.test/v1",
|
||||
status: 202,
|
||||
headers: { "x-trace": "precise" },
|
||||
})
|
||||
const frame = '{ "type": "failure", "debug": "not in schema" }'
|
||||
const failing = Route.make({
|
||||
id: "diagnostics",
|
||||
provider: "test",
|
||||
endpoint: route.endpoint,
|
||||
framing: Framing.sse,
|
||||
protocol: {
|
||||
...OpenAIChat.protocol,
|
||||
stream: {
|
||||
event: Schema.fromJsonString(Schema.Struct({ type: Schema.String })),
|
||||
initial: () => undefined,
|
||||
step: (_state, event) =>
|
||||
Effect.fail(
|
||||
new AIError({
|
||||
reason: new InvalidProviderOutputError({
|
||||
message: "Parser failed",
|
||||
body: body ?? JSON.stringify(event),
|
||||
http,
|
||||
cause,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
})
|
||||
const error = yield* LLMClient.generate(
|
||||
LLM.request({ model: failing.model({ id: "test" }), prompt: "Hello" }),
|
||||
).pipe(Effect.provide(fixedResponse(sseEvents(frame), { headers })), Effect.flip)
|
||||
|
||||
expect(error.message).toBe("Parser failed")
|
||||
expect(error.reason.body).toBe(body ?? frame)
|
||||
expect(error.reason.cause).toBe(cause)
|
||||
expect(error.reason.http).toBe(http)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("retains malformed assembled Anthropic hosted-tool arguments", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = '{"query": BROKEN}'
|
||||
const error = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model: Anthropic.configure({ apiKey: "test", baseURL: "https://provider.test" }).model("claude"),
|
||||
prompt: "Hello",
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "server_tool_use", id: "srv1", name: "web_search" },
|
||||
},
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "input_json_delta", partial_json: '{"query": ' },
|
||||
},
|
||||
{ type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: "BROKEN}" } },
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
),
|
||||
{ headers },
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.message).toContain("Invalid JSON input for anthropic-messages tool call web_search")
|
||||
expect(error.reason.body).toBe(body)
|
||||
expect(error.reason.cause).toBeInstanceOf(Error)
|
||||
expect(error.reason.http).toMatchObject({ status: 200, headers })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("adds successful HTTP metadata without replacing framing diagnostics", () =>
|
||||
Effect.gen(function* () {
|
||||
const cause = new Error("frame checksum mismatch")
|
||||
const failure = new AIError({
|
||||
reason: new InvalidProviderOutputError({
|
||||
message: "Invalid frame",
|
||||
body: "original frame representation",
|
||||
cause,
|
||||
}),
|
||||
})
|
||||
const failing = route.with({
|
||||
transport: HttpTransport.httpJson({ framing: { id: "failure", frame: () => Stream.fail(failure) } }),
|
||||
})
|
||||
const error = yield* LLMClient.generate(
|
||||
LLM.request({ model: failing.model({ id: "test" }), prompt: "Hello" }),
|
||||
).pipe(Effect.provide(fixedResponse("wire bytes", { headers })), Effect.flip)
|
||||
|
||||
expect(error.reason.body).toBe(failure.reason.body)
|
||||
expect(error.reason.cause).toBe(cause)
|
||||
expect(error.message).toBe(failure.message)
|
||||
expect(error.reason.http?.status).toBe(200)
|
||||
expect(error.reason.http?.headers).toEqual(headers)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retains the original read error after successful response headers", () =>
|
||||
Effect.gen(function* () {
|
||||
const cause = new Error("socket disconnected")
|
||||
const error = yield* LLMClient.generate(request).pipe(Effect.provide(truncatedStream([], cause)), Effect.flip)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "Transport", operation: "read" })
|
||||
expect(error.reason.cause).toBe(cause)
|
||||
expect(error.reason.http?.status).toBe(200)
|
||||
expect(error.reason.body).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retains successful response headers on an incomplete stream", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse("", { headers })), Effect.flip)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput", classification: "incomplete-stream" })
|
||||
expect(error.reason.http?.headers).toEqual(headers)
|
||||
expect(error.reason.body).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
+184
-10
@@ -4,14 +4,25 @@ import * as OpenAIChat from "../src/protocols/openai-chat.js"
|
||||
import * as OpenAIResponses from "../src/protocols/openai-responses.js"
|
||||
import {
|
||||
AIError,
|
||||
AIErrorReason,
|
||||
AuthenticationError,
|
||||
ContentPart,
|
||||
InvalidRequestReason,
|
||||
ContentPolicyError,
|
||||
HttpContext,
|
||||
InvalidProviderOutputError,
|
||||
InvalidRequestError,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
LanguageModel,
|
||||
ModelID,
|
||||
NoRouteError,
|
||||
ProviderID,
|
||||
TransportReason,
|
||||
ProviderInternalError,
|
||||
QuotaExceededError,
|
||||
RateLimitError,
|
||||
RouteID,
|
||||
TransportError,
|
||||
UnknownProviderError,
|
||||
Usage,
|
||||
} from "../src/schema/index.js"
|
||||
import { ProviderShared } from "../src/protocols/shared.js"
|
||||
@@ -144,19 +155,20 @@ describe("AI.Usage", () => {
|
||||
|
||||
test("AI errors expose the shared runtime tag", async () => {
|
||||
const error = new AIError({
|
||||
module: "test",
|
||||
method: "call",
|
||||
reason: new InvalidRequestReason({ message: "invalid" }),
|
||||
reason: new InvalidRequestError({ message: "invalid" }),
|
||||
})
|
||||
expect(error._tag).toBe("AI.Error")
|
||||
expect(error.message).toBe("invalid")
|
||||
expect(error.cause).toBe(error.reason)
|
||||
expect(error.reason.cause).toBeUndefined()
|
||||
expect(
|
||||
await Effect.runPromise(Effect.fail(error).pipe(Effect.catchTag("AI.Error", () => Effect.succeed("caught")))),
|
||||
).toBe("caught")
|
||||
})
|
||||
|
||||
test("transport errors serialize execution facts", () => {
|
||||
const reason = new TransportReason({
|
||||
message: "connection closed",
|
||||
const reason = new TransportError({
|
||||
message: "Connection closed",
|
||||
transport: "websocket",
|
||||
operation: "read",
|
||||
phase: "receive",
|
||||
@@ -164,14 +176,176 @@ test("transport errors serialize execution facts", () => {
|
||||
recovery: "fail",
|
||||
})
|
||||
|
||||
expect(Schema.encodeSync(TransportReason)(reason)).toEqual({
|
||||
expect(Schema.encodeSync(TransportError)(reason)).toEqual({
|
||||
_tag: "Transport",
|
||||
message: "connection closed",
|
||||
message: "Connection closed",
|
||||
transport: "websocket",
|
||||
operation: "read",
|
||||
phase: "receive",
|
||||
delivery: "ambiguous",
|
||||
recovery: "fail",
|
||||
})
|
||||
expect(Schema.decodeUnknownSync(TransportReason)(Schema.encodeSync(TransportReason)(reason))).toEqual(reason)
|
||||
expect(Schema.decodeUnknownSync(TransportError)(Schema.encodeSync(TransportError)(reason))).toEqual(reason)
|
||||
})
|
||||
|
||||
test("AI errors serialize diagnostics only on their typed reason", () => {
|
||||
const cause = new SyntaxError("Unexpected end of JSON input")
|
||||
const error = new AIError({
|
||||
reason: new InvalidRequestError({
|
||||
message: "Invalid provider response",
|
||||
body: '{"error":',
|
||||
http: new HttpContext({
|
||||
url: "https://provider.test/v1/messages",
|
||||
status: 400,
|
||||
headers: { "request-id": "req_123" },
|
||||
}),
|
||||
cause,
|
||||
parameter: "messages",
|
||||
classification: "context-overflow",
|
||||
}),
|
||||
})
|
||||
const encoded = Schema.encodeSync(AIError)(error)
|
||||
expect(encoded).toEqual({
|
||||
_tag: "AI.Error",
|
||||
reason: {
|
||||
_tag: "InvalidRequest",
|
||||
message: "Invalid provider response",
|
||||
body: '{"error":',
|
||||
http: {
|
||||
url: "https://provider.test/v1/messages",
|
||||
status: 400,
|
||||
headers: { "request-id": "req_123" },
|
||||
},
|
||||
cause: { name: "SyntaxError", message: cause.message, stack: cause.stack },
|
||||
parameter: "messages",
|
||||
classification: "context-overflow",
|
||||
},
|
||||
})
|
||||
const decoded = Schema.decodeUnknownSync(Schema.fromJsonString(AIError))(
|
||||
Schema.encodeSync(Schema.fromJsonString(AIError))(error),
|
||||
)
|
||||
|
||||
expect(error).not.toHaveProperty("body")
|
||||
expect(error).not.toHaveProperty("http")
|
||||
expect(error.cause).toBe(error.reason)
|
||||
expect(error.reason.cause).toBe(cause)
|
||||
expect(decoded).toBeInstanceOf(AIError)
|
||||
expect(decoded.reason).toBeInstanceOf(InvalidRequestError)
|
||||
expect(decoded.message).toBe("Invalid provider response")
|
||||
expect(decoded.reason.message).toBe(decoded.message)
|
||||
expect(decoded.reason.body).toBe('{"error":')
|
||||
expect(decoded.reason.http).toEqual(error.reason.http)
|
||||
expect(decoded.cause).toBe(decoded.reason)
|
||||
expect(decoded.reason.cause).toBeInstanceOf(Error)
|
||||
expect(decoded.reason.cause).toMatchObject({ name: "SyntaxError", message: cause.message, stack: cause.stack })
|
||||
expect(decoded.reason).toMatchObject({ parameter: "messages", classification: "context-overflow" })
|
||||
})
|
||||
|
||||
test("AI error reasons are tagged Errors with required messages", () => {
|
||||
const reasons = [
|
||||
new InvalidRequestError({ message: "Invalid request" }),
|
||||
new NoRouteError({
|
||||
message: "No route",
|
||||
route: RouteID.make("missing"),
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
}),
|
||||
new AuthenticationError({ message: "Missing credentials", kind: "missing" }),
|
||||
new RateLimitError({ message: "Rate limited" }),
|
||||
new QuotaExceededError({ message: "Quota exceeded" }),
|
||||
new ContentPolicyError({ message: "Content blocked" }),
|
||||
new ProviderInternalError({ message: "Provider failed" }),
|
||||
new TransportError({ message: "Connection failed", transport: "http", operation: "request" }),
|
||||
new InvalidProviderOutputError({ message: "Invalid output" }),
|
||||
new UnknownProviderError({ message: "Unknown failure" }),
|
||||
]
|
||||
expect(reasons.map((reason) => reason._tag)).toEqual([
|
||||
"InvalidRequest",
|
||||
"NoRoute",
|
||||
"Authentication",
|
||||
"RateLimit",
|
||||
"QuotaExceeded",
|
||||
"ContentPolicy",
|
||||
"ProviderInternal",
|
||||
"Transport",
|
||||
"InvalidProviderOutput",
|
||||
"UnknownProvider",
|
||||
])
|
||||
reasons.forEach((reason) => {
|
||||
expect(reason).toBeInstanceOf(Error)
|
||||
const encoded = Schema.encodeSync(AIErrorReason)(reason)
|
||||
const decoded = Schema.decodeUnknownSync(AIErrorReason)(encoded)
|
||||
expect(decoded).toBeInstanceOf(reason.constructor)
|
||||
expect(decoded.message).toBe(reason.message)
|
||||
expect(Schema.decodeUnknownOption(AIErrorReason)({ ...encoded, message: undefined })._tag).toBe("None")
|
||||
})
|
||||
})
|
||||
|
||||
test("AI error reason enrichment preserves non-enumerable diagnostics", () => {
|
||||
const cause = new Error("socket disconnected")
|
||||
const reason = new TransportError({
|
||||
message: "Connection closed",
|
||||
body: "close frame detail",
|
||||
http: new HttpContext({ url: "https://provider.test/responses", status: 101, headers: { upgrade: "websocket" } }),
|
||||
cause,
|
||||
transport: "websocket",
|
||||
operation: "read",
|
||||
phase: "close",
|
||||
})
|
||||
expect(Object.prototype.propertyIsEnumerable.call(reason, "message")).toBe(false)
|
||||
expect(Object.prototype.propertyIsEnumerable.call(reason, "cause")).toBe(false)
|
||||
const enriched = AIErrorReason.make({
|
||||
// oxlint-disable-next-line typescript-eslint/no-misused-spread -- Copy fields rather than iterating the yieldable error.
|
||||
...reason,
|
||||
message: reason.message,
|
||||
cause: reason.cause,
|
||||
delivery: "ambiguous",
|
||||
recovery: "retry-full",
|
||||
})
|
||||
const error = new AIError({ reason: enriched })
|
||||
|
||||
expect(enriched).toBeInstanceOf(TransportError)
|
||||
expect(error.message).toBe(reason.message)
|
||||
expect(error.cause).toBe(enriched)
|
||||
expect(enriched.cause).toBe(cause)
|
||||
expect(enriched.body).toBe(reason.body)
|
||||
expect(enriched.http).toBe(reason.http)
|
||||
expect(enriched).toMatchObject({ phase: "close", delivery: "ambiguous", recovery: "retry-full" })
|
||||
})
|
||||
|
||||
test("AI errors support reason-specific handlers", async () => {
|
||||
const limited = new AIError({ reason: new RateLimitError({ message: "Slow down", retryAfterMs: 2000 }) })
|
||||
const invalid = new AIError({ reason: new InvalidRequestError({ message: "Invalid request", parameter: "model" }) })
|
||||
expect(
|
||||
await Effect.runPromise(
|
||||
Effect.fail(limited).pipe(
|
||||
Effect.catchReason("AI.Error", "RateLimit", (reason) => {
|
||||
expect(reason).toBe(limited.reason)
|
||||
expect(reason).toBeInstanceOf(RateLimitError)
|
||||
return Effect.succeed(reason.retryAfterMs)
|
||||
}),
|
||||
),
|
||||
),
|
||||
).toBe(2000)
|
||||
expect(
|
||||
await Effect.runPromise(
|
||||
Effect.forEach([limited, invalid], (error) =>
|
||||
Effect.fail(error).pipe(
|
||||
Effect.catchReasons("AI.Error", {
|
||||
RateLimit: (reason) => Effect.succeed(reason.message),
|
||||
InvalidRequest: (reason) => Effect.succeed(reason.parameter),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
).toEqual(["Slow down", "model"])
|
||||
})
|
||||
|
||||
test("HTTP error context requires an observed response", () => {
|
||||
const decode = Schema.decodeUnknownOption(HttpContext)
|
||||
expect(decode({ status: 400, headers: {} })._tag).toBe("None")
|
||||
expect(decode({ url: "https://provider.test", headers: {} })._tag).toBe("None")
|
||||
expect(decode({ url: "https://provider.test", status: 400 })._tag).toBe("None")
|
||||
expect(decode({ url: "https://provider.test", status: 0, headers: {} })._tag).toBe("None")
|
||||
expect(decode({ url: "https://provider.test", status: Number.NaN, headers: {} })._tag).toBe("None")
|
||||
})
|
||||
|
||||
@@ -109,7 +109,7 @@ describe("ToolStream", () => {
|
||||
const error = ToolStream.appendExisting(ADAPTER, ToolStream.empty<number>(), 0, "{}", "missing tool")
|
||||
|
||||
expect(error).toBeInstanceOf(AIError)
|
||||
if (ToolStream.isError(error)) expect(error.reason.message).toBe("missing tool")
|
||||
if (ToolStream.isError(error)) expect(error.message).toBe("missing tool")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/pty": "0.1.12",
|
||||
"@opencode-ai/pty": "0.1.13",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"@opencode-ai/tui": "workspace:*",
|
||||
|
||||
@@ -27,8 +27,13 @@ export async function resolveOpencodePty(target: Target): Promise<OpencodePtyAss
|
||||
.map((value) => (value === "glibc" ? "gnu" : value))
|
||||
.join("-")
|
||||
const name = `@opencode-ai/pty-${suffix}`
|
||||
const source = pty.resolve(`${name}/bin/opencode-pty`)
|
||||
const manifest: unknown = JSON.parse(await readFile(pty.resolve(`${name}/package.json`), "utf8"))
|
||||
const local = process.env.OPENCODE_PTY_BIN
|
||||
if (local && (target.platform !== process.platform || target.arch !== process.arch))
|
||||
throw new Error("OPENCODE_PTY_BIN can only be embedded in a build for the current platform and architecture")
|
||||
const source = local ? path.resolve(local) : pty.resolve(`${name}/bin/opencode-pty`)
|
||||
const manifest: unknown = local
|
||||
? { version: "local" }
|
||||
: JSON.parse(await readFile(pty.resolve(`${name}/package.json`), "utf8"))
|
||||
if (!manifest || typeof manifest !== "object" || !("version" in manifest) || typeof manifest.version !== "string")
|
||||
throw new Error(`Invalid package metadata for ${name}`)
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ import { Global } from "@opencode-ai/util/global"
|
||||
import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { randomBytes, randomUUID } from "node:crypto"
|
||||
import { Effect, Option, Redacted, Schedule } from "effect"
|
||||
import { Effect, Option, Redacted, Schedule, Schema } from "effect"
|
||||
import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { Env } from "./env"
|
||||
import { ServiceConfig } from "./services/service-config"
|
||||
@@ -40,6 +41,14 @@ export const run = Effect.fnUntraced(function* (options: Options) {
|
||||
})
|
||||
|
||||
const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
const inherited = process.env.OPENCODE_PTY_HANDOFF
|
||||
delete process.env.OPENCODE_PTY_HANDOFF
|
||||
const handoff =
|
||||
inherited === undefined
|
||||
? undefined
|
||||
: yield* Schema.decodeUnknownEffect(Schema.fromJsonString(PersistentPty.Handoff))(inherited).pipe(
|
||||
Effect.mapError(() => new Error("Invalid PTY restart handoff")),
|
||||
)
|
||||
const global = yield* Global.Service
|
||||
if (options.mode === "service") yield* Effect.sync(() => process.chdir(global.home))
|
||||
return yield* Effect.scoped(
|
||||
@@ -80,6 +89,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
hostname,
|
||||
port,
|
||||
password,
|
||||
pty: { handoff },
|
||||
simulation: truthy(process.env.OPENCODE_SIMULATE),
|
||||
database: {
|
||||
path:
|
||||
|
||||
+247
-295
@@ -6,6 +6,7 @@ import { expect, test } from "bun:test"
|
||||
import { parse } from "jsonc-parser"
|
||||
import path from "path"
|
||||
import { Config } from "../src/config"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
|
||||
function run<A, E>(directory: string, effect: Effect.Effect<A, E, Config.Service>) {
|
||||
return Effect.runPromise(
|
||||
@@ -35,53 +36,45 @@ test("generates reusable keybind schemas and preserves descriptions and numeric
|
||||
})
|
||||
|
||||
test("includes the published schema when creating cli.json", async () => {
|
||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
await using directory = await tmpdir()
|
||||
|
||||
try {
|
||||
const config = await run(
|
||||
directory,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Config.Service
|
||||
return yield* service.update((draft) => {
|
||||
draft.animations = false
|
||||
})
|
||||
}),
|
||||
)
|
||||
const config = await run(
|
||||
directory.path,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Config.Service
|
||||
return yield* service.update((draft) => {
|
||||
draft.animations = false
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
expect(config).toEqual({ $schema: "https://opencode.ai/v2/cli.json", animations: false })
|
||||
expect(await Bun.file(path.join(directory, "cli.json")).json()).toEqual(config)
|
||||
} finally {
|
||||
await Bun.$`rm -rf ${directory}`
|
||||
}
|
||||
expect(config).toEqual({ $schema: "https://opencode.ai/v2/cli.json", animations: false })
|
||||
expect(await Bun.file(path.join(directory.path, "cli.json")).json()).toEqual(config)
|
||||
})
|
||||
|
||||
test("preserves the schema in an existing cli.json", async () => {
|
||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
const file = path.join(directory, "cli.json")
|
||||
await using directory = await tmpdir()
|
||||
const file = path.join(directory.path, "cli.json")
|
||||
await Bun.write(file, JSON.stringify({ $schema: "https://opencode.ai/v2/cli.json", animations: true }))
|
||||
|
||||
try {
|
||||
const config = await run(
|
||||
directory,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Config.Service
|
||||
return yield* service.update((draft) => {
|
||||
draft.animations = false
|
||||
})
|
||||
}),
|
||||
)
|
||||
const config = await run(
|
||||
directory.path,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Config.Service
|
||||
return yield* service.update((draft) => {
|
||||
draft.animations = false
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
expect(config).toEqual({ $schema: "https://opencode.ai/v2/cli.json", animations: false })
|
||||
expect(await Bun.file(file).json()).toEqual(config)
|
||||
} finally {
|
||||
await Bun.$`rm -rf ${directory}`
|
||||
}
|
||||
expect(config).toEqual({ $schema: "https://opencode.ai/v2/cli.json", animations: false })
|
||||
expect(await Bun.file(file).json()).toEqual(config)
|
||||
})
|
||||
|
||||
test("migrates tui and kv config into cli.json", async () => {
|
||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
await using directory = await tmpdir()
|
||||
await Bun.write(
|
||||
path.join(directory, "tui.json"),
|
||||
path.join(directory.path, "tui.json"),
|
||||
JSON.stringify({
|
||||
theme: "legacy",
|
||||
keybinds: {
|
||||
@@ -102,7 +95,7 @@ test("migrates tui and kv config into cli.json", async () => {
|
||||
}),
|
||||
)
|
||||
await Bun.write(
|
||||
path.join(directory, "kv.json"),
|
||||
path.join(directory.path, "kv.json"),
|
||||
JSON.stringify({
|
||||
theme_mode_lock: "light",
|
||||
attention_sound_pack: "custom.pack",
|
||||
@@ -124,115 +117,108 @@ test("migrates tui and kv config into cli.json", async () => {
|
||||
}),
|
||||
)
|
||||
|
||||
try {
|
||||
const config = await run(
|
||||
directory,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Config.Service
|
||||
return yield* service.get()
|
||||
}),
|
||||
)
|
||||
const config = await run(
|
||||
directory.path,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Config.Service
|
||||
return yield* service.get()
|
||||
}),
|
||||
)
|
||||
|
||||
expect(config).toMatchObject({
|
||||
$schema: "https://opencode.ai/v2/cli.json",
|
||||
theme: { name: "legacy", mode: "light" },
|
||||
keybinds: {
|
||||
leader: "ctrl+o",
|
||||
"app.exit": "ctrl+q",
|
||||
"prompt.paste": { key: "ctrl+v", preventDefault: false },
|
||||
"session.delete": false,
|
||||
"dialog.select.next": "ctrl+n",
|
||||
},
|
||||
plugins: [{ package: "example", options: { mode: "safe" } }, "-disabled"],
|
||||
leader: { timeout: 500 },
|
||||
scroll: { speed: 2, acceleration: true },
|
||||
attention: { sound_pack: "custom.pack" },
|
||||
diffs: { wrap: "none", tree: false, single: true, view: "split" },
|
||||
terminal: { title: false },
|
||||
prompt: { editor: false, paste: "full" },
|
||||
session: { sidebar: "hide", scrollbar: true, thinking: "show", grouping: "none" },
|
||||
animations: false,
|
||||
mouse: false,
|
||||
})
|
||||
expect(config).not.toHaveProperty("skipped_version")
|
||||
expect(config).not.toHaveProperty("which_key")
|
||||
expect(config).not.toHaveProperty("hints")
|
||||
expect((await Bun.file(path.join(directory, "cli.json")).json()).keybinds).toEqual({
|
||||
expect(config).toMatchObject({
|
||||
$schema: "https://opencode.ai/v2/cli.json",
|
||||
theme: { name: "legacy", mode: "light" },
|
||||
keybinds: {
|
||||
leader: "ctrl+o",
|
||||
"app.exit": "ctrl+q",
|
||||
"prompt.paste": { key: "ctrl+v", preventDefault: false },
|
||||
"session.delete": false,
|
||||
"dialog.select.next": "ctrl+n",
|
||||
})
|
||||
expect(await Bun.file(path.join(directory, "cli.json")).exists()).toBe(true)
|
||||
expect(await Bun.file(path.join(directory, "tui.json")).exists()).toBe(true)
|
||||
expect(await Bun.file(path.join(directory, "kv.json")).exists()).toBe(true)
|
||||
} finally {
|
||||
await Bun.$`rm -rf ${directory}`
|
||||
}
|
||||
},
|
||||
plugins: [{ package: "example", options: { mode: "safe" } }, "-disabled"],
|
||||
leader: { timeout: 500 },
|
||||
scroll: { speed: 2, acceleration: true },
|
||||
attention: { sound_pack: "custom.pack" },
|
||||
diffs: { wrap: "none", tree: false, single: true, view: "split" },
|
||||
terminal: { title: false },
|
||||
prompt: { editor: false, paste: "full" },
|
||||
session: { sidebar: "hide", scrollbar: true, thinking: "show", grouping: "none" },
|
||||
animations: false,
|
||||
mouse: false,
|
||||
})
|
||||
expect(config).not.toHaveProperty("skipped_version")
|
||||
expect(config).not.toHaveProperty("which_key")
|
||||
expect(config).not.toHaveProperty("hints")
|
||||
expect((await Bun.file(path.join(directory.path, "cli.json")).json()).keybinds).toEqual({
|
||||
leader: "ctrl+o",
|
||||
"app.exit": "ctrl+q",
|
||||
"prompt.paste": { key: "ctrl+v", preventDefault: false },
|
||||
"session.delete": false,
|
||||
"dialog.select.next": "ctrl+n",
|
||||
})
|
||||
expect(await Bun.file(path.join(directory.path, "cli.json")).exists()).toBe(true)
|
||||
expect(await Bun.file(path.join(directory.path, "tui.json")).exists()).toBe(true)
|
||||
expect(await Bun.file(path.join(directory.path, "kv.json")).exists()).toBe(true)
|
||||
})
|
||||
|
||||
test("migrates before the first update and does not remigrate afterward", async () => {
|
||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
await Bun.write(path.join(directory, "tui.json"), JSON.stringify({ theme: "legacy" }))
|
||||
await using directory = await tmpdir()
|
||||
await Bun.write(path.join(directory.path, "tui.json"), JSON.stringify({ theme: "legacy" }))
|
||||
|
||||
try {
|
||||
const config = await run(
|
||||
directory,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Config.Service
|
||||
yield* service.update((draft) => {
|
||||
draft.animations = false
|
||||
draft.mouse = false
|
||||
})
|
||||
yield* Effect.promise(() => Bun.write(path.join(directory, "tui.json"), JSON.stringify({ theme: "changed" })))
|
||||
return yield* service.get()
|
||||
}),
|
||||
)
|
||||
const config = await run(
|
||||
directory.path,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Config.Service
|
||||
yield* service.update((draft) => {
|
||||
draft.animations = false
|
||||
draft.mouse = false
|
||||
})
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(path.join(directory.path, "tui.json"), JSON.stringify({ theme: "changed" })),
|
||||
)
|
||||
return yield* service.get()
|
||||
}),
|
||||
)
|
||||
|
||||
expect(config).toEqual({
|
||||
$schema: "https://opencode.ai/v2/cli.json",
|
||||
theme: { name: "legacy" },
|
||||
animations: false,
|
||||
mouse: false,
|
||||
})
|
||||
expect(await Bun.file(path.join(directory, "cli.json")).json()).toEqual({
|
||||
$schema: "https://opencode.ai/v2/cli.json",
|
||||
theme: { name: "legacy" },
|
||||
animations: false,
|
||||
mouse: false,
|
||||
})
|
||||
} finally {
|
||||
await Bun.$`rm -rf ${directory}`
|
||||
}
|
||||
expect(config).toEqual({
|
||||
$schema: "https://opencode.ai/v2/cli.json",
|
||||
theme: { name: "legacy" },
|
||||
animations: false,
|
||||
mouse: false,
|
||||
})
|
||||
expect(await Bun.file(path.join(directory.path, "cli.json")).json()).toEqual({
|
||||
$schema: "https://opencode.ai/v2/cli.json",
|
||||
theme: { name: "legacy" },
|
||||
animations: false,
|
||||
mouse: false,
|
||||
})
|
||||
})
|
||||
|
||||
test("preserves legacy cursor settings", async () => {
|
||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
await Bun.write(path.join(directory, "tui.json"), JSON.stringify({ cursor: { style: "underline", blinking: false } }))
|
||||
await using directory = await tmpdir()
|
||||
await Bun.write(
|
||||
path.join(directory.path, "tui.json"),
|
||||
JSON.stringify({ cursor: { style: "underline", blinking: false } }),
|
||||
)
|
||||
|
||||
try {
|
||||
const config = await run(
|
||||
directory,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Config.Service
|
||||
return yield* service.get()
|
||||
}),
|
||||
)
|
||||
const config = await run(
|
||||
directory.path,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Config.Service
|
||||
return yield* service.get()
|
||||
}),
|
||||
)
|
||||
|
||||
expect(config.cursor).toEqual({ style: "underline", blinking: false })
|
||||
expect((await Bun.file(path.join(directory, "cli.json")).json()).cursor).toEqual({
|
||||
style: "underline",
|
||||
blinking: false,
|
||||
})
|
||||
} finally {
|
||||
await Bun.$`rm -rf ${directory}`
|
||||
}
|
||||
expect(config.cursor).toEqual({ style: "underline", blinking: false })
|
||||
expect((await Bun.file(path.join(directory.path, "cli.json")).json()).cursor).toEqual({
|
||||
style: "underline",
|
||||
blinking: false,
|
||||
})
|
||||
})
|
||||
|
||||
test("migrates legacy keybind names in an existing cli.json", async () => {
|
||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
const file = path.join(directory, "cli.json")
|
||||
await using directory = await tmpdir()
|
||||
const file = path.join(directory.path, "cli.json")
|
||||
await Bun.write(
|
||||
file,
|
||||
`{
|
||||
@@ -251,31 +237,27 @@ test("migrates legacy keybind names in an existing cli.json", async () => {
|
||||
`,
|
||||
)
|
||||
|
||||
try {
|
||||
const config = await run(
|
||||
directory,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Config.Service
|
||||
return yield* service.get()
|
||||
}),
|
||||
)
|
||||
const config = await run(
|
||||
directory.path,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Config.Service
|
||||
return yield* service.get()
|
||||
}),
|
||||
)
|
||||
|
||||
expect(config.keybinds).toEqual({
|
||||
"session.list": "ctrl+l",
|
||||
"session.delete": "ctrl+x",
|
||||
})
|
||||
const text = await Bun.file(file).text()
|
||||
expect(text).toContain("// Preserve this comment")
|
||||
expect(text).toContain("// Session list shortcut")
|
||||
expect(text).toContain("// Legacy delete shortcut")
|
||||
expect(text).toContain("// Canonical delete shortcut")
|
||||
expect(parse(text).keybinds).toEqual({
|
||||
"session.list": "ctrl+l",
|
||||
"session.delete": "ctrl+x",
|
||||
})
|
||||
} finally {
|
||||
await Bun.$`rm -rf ${directory}`
|
||||
}
|
||||
expect(config.keybinds).toEqual({
|
||||
"session.list": "ctrl+l",
|
||||
"session.delete": "ctrl+x",
|
||||
})
|
||||
const text = await Bun.file(file).text()
|
||||
expect(text).toContain("// Preserve this comment")
|
||||
expect(text).toContain("// Session list shortcut")
|
||||
expect(text).toContain("// Legacy delete shortcut")
|
||||
expect(text).toContain("// Canonical delete shortcut")
|
||||
expect(parse(text).keybinds).toEqual({
|
||||
"session.list": "ctrl+l",
|
||||
"session.delete": "ctrl+x",
|
||||
})
|
||||
})
|
||||
|
||||
test("migrates copy_on_select in an existing cli.json", async () => {
|
||||
@@ -284,8 +266,8 @@ test("migrates copy_on_select in an existing cli.json", async () => {
|
||||
{ legacy: false, expected: "manual" },
|
||||
{ legacy: true, copy: "manual", expected: "manual" },
|
||||
] as const) {
|
||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
const file = path.join(directory, "cli.json")
|
||||
await using directory = await tmpdir()
|
||||
const file = path.join(directory.path, "cli.json")
|
||||
await Bun.write(
|
||||
file,
|
||||
`{
|
||||
@@ -298,29 +280,25 @@ test("migrates copy_on_select in an existing cli.json", async () => {
|
||||
`,
|
||||
)
|
||||
|
||||
try {
|
||||
const config = await run(
|
||||
directory,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Config.Service
|
||||
return yield* service.get()
|
||||
}),
|
||||
)
|
||||
const config = await run(
|
||||
directory.path,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Config.Service
|
||||
return yield* service.get()
|
||||
}),
|
||||
)
|
||||
|
||||
expect(config.terminal).toEqual({ copy: item.expected })
|
||||
const text = await Bun.file(file).text()
|
||||
expect(text).toContain("// Preserve this comment")
|
||||
expect(text).not.toContain("copy_on_select")
|
||||
expect(parse(text).terminal).toEqual({ copy: item.expected })
|
||||
} finally {
|
||||
await Bun.$`rm -rf ${directory}`
|
||||
}
|
||||
expect(config.terminal).toEqual({ copy: item.expected })
|
||||
const text = await Bun.file(file).text()
|
||||
expect(text).toContain("// Preserve this comment")
|
||||
expect(text).not.toContain("copy_on_select")
|
||||
expect(parse(text).terminal).toEqual({ copy: item.expected })
|
||||
}
|
||||
})
|
||||
|
||||
test("uses migrated keybinds when persistence fails", async () => {
|
||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
const file = path.join(directory, "cli.json")
|
||||
await using directory = await tmpdir()
|
||||
const file = path.join(directory.path, "cli.json")
|
||||
await Bun.write(file, `{"keybinds":{"session_list":"ctrl+l"}}`)
|
||||
const node = await Effect.runPromise(FileSystem.FileSystem.pipe(Effect.provide(NodeFileSystem.layer)))
|
||||
const fs = new Proxy(node, {
|
||||
@@ -330,88 +308,76 @@ test("uses migrated keybinds when persistence fails", async () => {
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const config = await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Config.Service
|
||||
return yield* service.get()
|
||||
}).pipe(
|
||||
Effect.provide(Config.layer),
|
||||
Effect.provide(Global.layerWith({ config: directory, state: directory })),
|
||||
Effect.provideService(FileSystem.FileSystem, fs),
|
||||
),
|
||||
)
|
||||
const config = await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Config.Service
|
||||
return yield* service.get()
|
||||
}).pipe(
|
||||
Effect.provide(Config.layer),
|
||||
Effect.provide(Global.layerWith({ config: directory.path, state: directory.path })),
|
||||
Effect.provideService(FileSystem.FileSystem, fs),
|
||||
),
|
||||
)
|
||||
|
||||
expect(config.keybinds).toEqual({ "session.list": "ctrl+l" })
|
||||
expect(await Bun.file(file).json()).toEqual({ keybinds: { session_list: "ctrl+l" } })
|
||||
expect(await Array.fromAsync(new Bun.Glob("*.tmp").scan(directory))).toEqual([])
|
||||
} finally {
|
||||
await Bun.$`rm -rf ${directory}`
|
||||
}
|
||||
expect(config.keybinds).toEqual({ "session.list": "ctrl+l" })
|
||||
expect(await Bun.file(file).json()).toEqual({ keybinds: { session_list: "ctrl+l" } })
|
||||
expect(await Array.fromAsync(new Bun.Glob("*.tmp").scan(directory.path))).toEqual([])
|
||||
})
|
||||
|
||||
test("preserves the effective value when migrating duplicate legacy keybinds", async () => {
|
||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
const file = path.join(directory, "cli.json")
|
||||
await using directory = await tmpdir()
|
||||
const file = path.join(directory.path, "cli.json")
|
||||
await Bun.write(file, `{"keybinds":{"session_delete":"ctrl+a","session_delete":"ctrl+b"}}`)
|
||||
|
||||
try {
|
||||
const config = await run(
|
||||
directory,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Config.Service
|
||||
return yield* service.get()
|
||||
}),
|
||||
)
|
||||
const config = await run(
|
||||
directory.path,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Config.Service
|
||||
return yield* service.get()
|
||||
}),
|
||||
)
|
||||
|
||||
expect(config.keybinds).toEqual({ "session.delete": "ctrl+b" })
|
||||
expect(parse(await Bun.file(file).text()).keybinds).toEqual({ "session.delete": "ctrl+b" })
|
||||
} finally {
|
||||
await Bun.$`rm -rf ${directory}`
|
||||
}
|
||||
expect(config.keybinds).toEqual({ "session.delete": "ctrl+b" })
|
||||
expect(parse(await Bun.file(file).text()).keybinds).toEqual({ "session.delete": "ctrl+b" })
|
||||
})
|
||||
|
||||
test("migrates and updates the effective duplicate top-level keybinds", async () => {
|
||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
const file = path.join(directory, "cli.json")
|
||||
await using directory = await tmpdir()
|
||||
const file = path.join(directory.path, "cli.json")
|
||||
await Bun.write(file, `{"keybinds":{"session_delete":"first"},"keybinds":{"session_delete":"last"}}`)
|
||||
|
||||
try {
|
||||
const config = await run(
|
||||
directory,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Config.Service
|
||||
expect((yield* service.get()).keybinds).toEqual({ "session.delete": "last" })
|
||||
return yield* service.update((draft) => {
|
||||
draft.keybinds = { ...draft.keybinds, "session.delete": "changed" }
|
||||
})
|
||||
}),
|
||||
)
|
||||
const config = await run(
|
||||
directory.path,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Config.Service
|
||||
expect((yield* service.get()).keybinds).toEqual({ "session.delete": "last" })
|
||||
return yield* service.update((draft) => {
|
||||
draft.keybinds = { ...draft.keybinds, "session.delete": "changed" }
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
expect(config.keybinds).toEqual({ "session.delete": "changed" })
|
||||
expect(parse(await Bun.file(file).text()).keybinds).toEqual({ "session.delete": "changed" })
|
||||
} finally {
|
||||
await Bun.$`rm -rf ${directory}`
|
||||
}
|
||||
expect(config.keybinds).toEqual({ "session.delete": "changed" })
|
||||
expect(parse(await Bun.file(file).text()).keybinds).toEqual({ "session.delete": "changed" })
|
||||
})
|
||||
|
||||
test("serializes migration and updates across processes", async () => {
|
||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
const file = path.join(directory, "cli.json")
|
||||
const started = path.join(directory, "started")
|
||||
const release = path.join(directory, "release")
|
||||
const migrateReady = path.join(directory, "migrate-ready")
|
||||
const updateReady = path.join(directory, "update-ready")
|
||||
await using directory = await tmpdir()
|
||||
const file = path.join(directory.path, "cli.json")
|
||||
const started = path.join(directory.path, "started")
|
||||
const release = path.join(directory.path, "release")
|
||||
const migrateReady = path.join(directory.path, "migrate-ready")
|
||||
const updateReady = path.join(directory.path, "update-ready")
|
||||
await Bun.write(file, `{"keybinds":{"session_delete":"ctrl+d"}}`)
|
||||
const worker = path.join(import.meta.dir, "fixture/config-concurrency.ts")
|
||||
const migrate = Bun.spawn([process.execPath, worker, "migrate", directory, started, release, migrateReady], {
|
||||
const migrate = Bun.spawn([process.execPath, worker, "migrate", directory.path, started, release, migrateReady], {
|
||||
stdout: "ignore",
|
||||
stderr: "pipe",
|
||||
})
|
||||
|
||||
try {
|
||||
await waitForFile(started, migrate.exited)
|
||||
const update = Bun.spawn([process.execPath, worker, "update", directory, started, release, updateReady], {
|
||||
const update = Bun.spawn([process.execPath, worker, "update", directory.path, started, release, updateReady], {
|
||||
stdout: "ignore",
|
||||
stderr: "pipe",
|
||||
})
|
||||
@@ -431,21 +397,20 @@ test("serializes migration and updates across processes", async () => {
|
||||
await Bun.write(release, "")
|
||||
migrate.kill()
|
||||
await migrate.exited
|
||||
await Bun.$`rm -rf ${directory}`
|
||||
}
|
||||
})
|
||||
|
||||
test("config reads remain interruptible while waiting for the file lock", async () => {
|
||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
const file = path.join(directory, "cli.json")
|
||||
const locks = path.join(directory, "locks")
|
||||
await using directory = await tmpdir()
|
||||
const file = path.join(directory.path, "cli.json")
|
||||
const locks = path.join(directory.path, "locks")
|
||||
const held = await Flock.acquire(file, { dir: locks })
|
||||
|
||||
try {
|
||||
const service = await Effect.runPromise(
|
||||
Config.Service.pipe(
|
||||
Effect.provide(Config.layer),
|
||||
Effect.provide(Global.layerWith({ config: directory, state: directory })),
|
||||
Effect.provide(Global.layerWith({ config: directory.path, state: directory.path })),
|
||||
Effect.provide(NodeFileSystem.layer),
|
||||
),
|
||||
)
|
||||
@@ -453,43 +418,38 @@ test("config reads remain interruptible while waiting for the file lock", async
|
||||
expect(await Promise.race([result, Bun.sleep(250).then(() => "blocked" as const)])).toEqual(Option.none())
|
||||
} finally {
|
||||
await held.release()
|
||||
await Bun.$`rm -rf ${directory}`
|
||||
}
|
||||
})
|
||||
|
||||
test("updates effective duplicate canonical keybinds", async () => {
|
||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
const file = path.join(directory, "cli.json")
|
||||
await using directory = await tmpdir()
|
||||
const file = path.join(directory.path, "cli.json")
|
||||
await Bun.write(
|
||||
file,
|
||||
`{"keybinds":{"session.delete":"first","session.delete":"last","permission.mode":"off","permission.mode":"on"}}`,
|
||||
)
|
||||
|
||||
try {
|
||||
const config = await run(
|
||||
directory,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Config.Service
|
||||
expect((yield* service.get()).keybinds).toEqual({ "session.delete": "last", "permission.mode": "on" })
|
||||
return yield* service.update((draft) => {
|
||||
draft.keybinds = { ...draft.keybinds, "session.delete": "changed", "permission.mode": "changed" }
|
||||
})
|
||||
}),
|
||||
)
|
||||
const config = await run(
|
||||
directory.path,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Config.Service
|
||||
expect((yield* service.get()).keybinds).toEqual({ "session.delete": "last", "permission.mode": "on" })
|
||||
return yield* service.update((draft) => {
|
||||
draft.keybinds = { ...draft.keybinds, "session.delete": "changed", "permission.mode": "changed" }
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
expect(config.keybinds).toEqual({ "session.delete": "changed", "permission.mode": "changed" })
|
||||
expect(parse(await Bun.file(file).text()).keybinds).toEqual({
|
||||
"session.delete": "changed",
|
||||
"permission.mode": "changed",
|
||||
})
|
||||
} finally {
|
||||
await Bun.$`rm -rf ${directory}`
|
||||
}
|
||||
expect(config.keybinds).toEqual({ "session.delete": "changed", "permission.mode": "changed" })
|
||||
expect(parse(await Bun.file(file).text()).keybinds).toEqual({
|
||||
"session.delete": "changed",
|
||||
"permission.mode": "changed",
|
||||
})
|
||||
})
|
||||
|
||||
test("removes orphaned keybinds without deleting trailing comments", async () => {
|
||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
const file = path.join(directory, "cli.json")
|
||||
await using directory = await tmpdir()
|
||||
const file = path.join(directory.path, "cli.json")
|
||||
await Bun.write(
|
||||
file,
|
||||
`{
|
||||
@@ -501,50 +461,42 @@ test("removes orphaned keybinds without deleting trailing comments", async () =>
|
||||
`,
|
||||
)
|
||||
|
||||
try {
|
||||
const config = await run(
|
||||
directory,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Config.Service
|
||||
return yield* service.get()
|
||||
}),
|
||||
)
|
||||
const config = await run(
|
||||
directory.path,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Config.Service
|
||||
return yield* service.get()
|
||||
}),
|
||||
)
|
||||
|
||||
expect(config.keybinds).toEqual({})
|
||||
const text = await Bun.file(file).text()
|
||||
expect(text).toContain("/* Keep legacy explanation */")
|
||||
expect(text).toContain("/* Keep canonical explanation */")
|
||||
expect(parse(text).keybinds).toEqual({})
|
||||
} finally {
|
||||
await Bun.$`rm -rf ${directory}`
|
||||
}
|
||||
expect(config.keybinds).toEqual({})
|
||||
const text = await Bun.file(file).text()
|
||||
expect(text).toContain("/* Keep legacy explanation */")
|
||||
expect(text).toContain("/* Keep canonical explanation */")
|
||||
expect(parse(text).keybinds).toEqual({})
|
||||
})
|
||||
|
||||
test("updates a config draft while preserving JSONC comments", async () => {
|
||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
await Bun.write(path.join(directory, "cli.json"), '{\n // Keep this comment\n "animations": true\n}\n')
|
||||
await using directory = await tmpdir()
|
||||
await Bun.write(path.join(directory.path, "cli.json"), '{\n // Keep this comment\n "animations": true\n}\n')
|
||||
|
||||
try {
|
||||
const config = await run(
|
||||
directory,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Config.Service
|
||||
return yield* service.update((draft) => {
|
||||
draft.prompt = { paste: "compact" }
|
||||
draft.mini = { thinking: "hide", shell_output: "hide", turn_summary: "hide", splash: "hide", mono: true }
|
||||
})
|
||||
}),
|
||||
)
|
||||
const config = await run(
|
||||
directory.path,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Config.Service
|
||||
return yield* service.update((draft) => {
|
||||
draft.prompt = { paste: "compact" }
|
||||
draft.mini = { thinking: "hide", shell_output: "hide", turn_summary: "hide", splash: "hide", mono: true }
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
expect(config).toEqual({
|
||||
animations: true,
|
||||
prompt: { paste: "compact" },
|
||||
mini: { thinking: "hide", shell_output: "hide", turn_summary: "hide", splash: "hide", mono: true },
|
||||
})
|
||||
expect(await Bun.file(path.join(directory, "cli.json")).text()).toContain("// Keep this comment")
|
||||
} finally {
|
||||
await Bun.$`rm -rf ${directory}`
|
||||
}
|
||||
expect(config).toEqual({
|
||||
animations: true,
|
||||
prompt: { paste: "compact" },
|
||||
mini: { thinking: "hide", shell_output: "hide", turn_summary: "hide", splash: "hide", mono: true },
|
||||
})
|
||||
expect(await Bun.file(path.join(directory.path, "cli.json")).text()).toContain("// Keep this comment")
|
||||
})
|
||||
|
||||
async function waitForFile(file: string, exited: Promise<number>) {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
|
||||
export async function tmpdir() {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-cli-test-"))
|
||||
return {
|
||||
path: directory,
|
||||
async [Symbol.asyncDispose]() {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { mkdtemp, rm } from "node:fs/promises"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "node:path"
|
||||
import { Readable } from "node:stream"
|
||||
import { pathToFileURL } from "node:url"
|
||||
@@ -10,20 +9,14 @@ import {
|
||||
type InteractiveStdin,
|
||||
usingInteractiveStdin,
|
||||
} from "../src/mini-host"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
|
||||
const temporary: string[] = []
|
||||
const model = { providerID: "openai", modelID: "gpt-5" }
|
||||
|
||||
function stream(isTTY: boolean) {
|
||||
return Object.assign(new Readable({ read() {} }), { isTTY }) as NodeJS.ReadStream
|
||||
}
|
||||
|
||||
async function root() {
|
||||
const directory = await mkdtemp(path.join(import.meta.dir, ".mini-host-"))
|
||||
temporary.push(directory)
|
||||
return directory
|
||||
}
|
||||
|
||||
function host(terminal: InteractiveStdin, directory: string) {
|
||||
return createMiniHost({
|
||||
terminal,
|
||||
@@ -32,10 +25,6 @@ function host(terminal: InteractiveStdin, directory: string) {
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporary.splice(0).map((directory) => rm(directory, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe("Mini CLI host", () => {
|
||||
test("reuses tty stdin without taking ownership", () => {
|
||||
const stdin = stream(true)
|
||||
@@ -129,30 +118,36 @@ describe("Mini CLI host", () => {
|
||||
})
|
||||
|
||||
test("subscribes and unsubscribes process signals through host capabilities", async () => {
|
||||
const input = host({ stdin: stream(true), cleanup() {} }, await root())
|
||||
await using directory = await tmpdir()
|
||||
const input = host({ stdin: stream(true), cleanup() {} }, directory.path)
|
||||
const sigint = process.listenerCount("SIGINT")
|
||||
const sigusr2 = process.listenerCount("SIGUSR2")
|
||||
const offInt = input.signals.sigint.subscribe(() => {})
|
||||
const offTheme = input.signals.sigusr2.subscribe(() => {})
|
||||
|
||||
expect(process.listenerCount("SIGINT")).toBe(sigint + 1)
|
||||
expect(process.listenerCount("SIGUSR2")).toBe(sigusr2 + 1)
|
||||
offInt()
|
||||
offInt()
|
||||
offTheme()
|
||||
offTheme()
|
||||
expect(process.listenerCount("SIGINT")).toBe(sigint)
|
||||
expect(process.listenerCount("SIGUSR2")).toBe(sigusr2)
|
||||
try {
|
||||
expect(process.listenerCount("SIGINT")).toBe(sigint + 1)
|
||||
expect(process.listenerCount("SIGUSR2")).toBe(sigusr2 + 1)
|
||||
offInt()
|
||||
offInt()
|
||||
offTheme()
|
||||
offTheme()
|
||||
expect(process.listenerCount("SIGINT")).toBe(sigint)
|
||||
expect(process.listenerCount("SIGUSR2")).toBe(sigusr2)
|
||||
} finally {
|
||||
offInt()
|
||||
offTheme()
|
||||
}
|
||||
})
|
||||
|
||||
test("passes frontend host capabilities", async () => {
|
||||
const directory = await root()
|
||||
const input = host({ stdin: stream(true), cleanup() {} }, directory)
|
||||
await using directory = await tmpdir()
|
||||
const input = host({ stdin: stream(true), cleanup() {} }, directory.path)
|
||||
|
||||
expect(input.paths).toEqual({ home: directory })
|
||||
expect(input.paths).toEqual({ home: directory.path })
|
||||
expect(input.platform).toBe(process.platform)
|
||||
expect(typeof input.files.readText).toBe("function")
|
||||
const file = path.join(directory, "attachment.txt")
|
||||
const file = path.join(directory.path, "attachment.txt")
|
||||
await Bun.write(file, "attachment contents")
|
||||
expect(await input.files.readText(pathToFileURL(file).href)).toBe("attachment contents")
|
||||
expect(typeof input.startup.showTiming).toBe("boolean")
|
||||
@@ -160,9 +155,9 @@ describe("Mini CLI host", () => {
|
||||
})
|
||||
|
||||
test("delegates model variant preferences", async () => {
|
||||
const directory = await root()
|
||||
const input = host({ stdin: stream(true), cleanup() {} }, directory)
|
||||
const file = path.join(directory, "model.json")
|
||||
await using directory = await tmpdir()
|
||||
const input = host({ stdin: stream(true), cleanup() {} }, directory.path)
|
||||
const file = path.join(directory.path, "model.json")
|
||||
|
||||
await input.preferences.saveVariant(model, "high")
|
||||
expect(await input.preferences.resolveVariant(model)).toBe("high")
|
||||
|
||||
@@ -2,6 +2,7 @@ import { expect, test } from "bun:test"
|
||||
import path from "node:path"
|
||||
import { parse } from "jsonc-parser"
|
||||
import { configurationTarget, writePluginConfig } from "../src/commands/handlers/plugin/add"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
|
||||
test("routes packages according to their exported runtimes", () => {
|
||||
expect(configurationTarget("server.js", "tui.js")).toBe("server")
|
||||
@@ -11,20 +12,16 @@ test("routes packages according to their exported runtimes", () => {
|
||||
})
|
||||
|
||||
test("adds a package to global plugin config without replacing unrelated settings", async () => {
|
||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
const file = path.join(directory, "opencode.jsonc")
|
||||
await using directory = await tmpdir()
|
||||
const file = path.join(directory.path, "opencode.jsonc")
|
||||
await Bun.write(file, '{\n // retained\n "model": "provider/model",\n "plugins": ["first"]\n}\n')
|
||||
|
||||
try {
|
||||
expect(await writePluginConfig(file, "second@1.0.0")).toBe(true)
|
||||
expect(await writePluginConfig(file, "second@1.0.0")).toBe(false)
|
||||
const text = await Bun.file(file).text()
|
||||
expect(text).toContain("// retained")
|
||||
expect(parse(text)).toEqual({
|
||||
model: "provider/model",
|
||||
plugins: ["first", "second@1.0.0"],
|
||||
})
|
||||
} finally {
|
||||
await Bun.$`rm -rf ${directory}`
|
||||
}
|
||||
expect(await writePluginConfig(file, "second@1.0.0")).toBe(true)
|
||||
expect(await writePluginConfig(file, "second@1.0.0")).toBe(false)
|
||||
const text = await Bun.file(file).text()
|
||||
expect(text).toContain("// retained")
|
||||
expect(parse(text)).toEqual({
|
||||
model: "provider/model",
|
||||
plugins: ["first", "second@1.0.0"],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,22 +2,19 @@ import { expect, test } from "bun:test"
|
||||
import path from "node:path"
|
||||
import { parse } from "jsonc-parser"
|
||||
import { removePluginConfig } from "../src/commands/handlers/plugin/remove"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
|
||||
test("removes string and object package entries without replacing unrelated settings", async () => {
|
||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
const file = path.join(directory, "opencode.jsonc")
|
||||
await using directory = await tmpdir()
|
||||
const file = path.join(directory.path, "opencode.jsonc")
|
||||
await Bun.write(
|
||||
file,
|
||||
'{\n // retained\n "model": "provider/model",\n "plugins": ["remove-me", { "package": "remove-me", "options": {} }, "keep-me"]\n}\n',
|
||||
)
|
||||
|
||||
try {
|
||||
expect(await removePluginConfig(file, "remove-me")).toBe(true)
|
||||
expect(await removePluginConfig(file, "remove-me")).toBe(false)
|
||||
const text = await Bun.file(file).text()
|
||||
expect(text).toContain("// retained")
|
||||
expect(parse(text)).toEqual({ model: "provider/model", plugins: ["keep-me"] })
|
||||
} finally {
|
||||
await Bun.$`rm -rf ${directory}`
|
||||
}
|
||||
expect(await removePluginConfig(file, "remove-me")).toBe(true)
|
||||
expect(await removePluginConfig(file, "remove-me")).toBe(false)
|
||||
const text = await Bun.file(file).text()
|
||||
expect(text).toContain("// retained")
|
||||
expect(parse(text)).toEqual({ model: "provider/model", plugins: ["keep-me"] })
|
||||
})
|
||||
|
||||
@@ -1658,9 +1658,9 @@ export type ExperimentalPersistentPtyListOperation<E = never> = (
|
||||
|
||||
export type ExperimentalPersistentPtyCreateInput = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly command: string
|
||||
readonly command?: string | undefined
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly cwd?: string | undefined
|
||||
readonly title: string
|
||||
readonly env: { readonly [x: string]: string }
|
||||
readonly size?: { readonly cols: number; readonly rows: number } | undefined
|
||||
@@ -1689,6 +1689,19 @@ export type ExperimentalPersistentPtyShutdownOperation<E = never> = () => Effect
|
||||
E
|
||||
>
|
||||
|
||||
export type ExperimentalPersistentPtyHandoffOutput = {
|
||||
readonly handoff: {
|
||||
readonly directory: string
|
||||
readonly instanceID: string
|
||||
readonly ticket: string
|
||||
readonly expiresAt: number
|
||||
} | null
|
||||
}
|
||||
export type ExperimentalPersistentPtyHandoffOperation<E = never> = () => Effect.Effect<
|
||||
ExperimentalPersistentPtyHandoffOutput,
|
||||
E
|
||||
>
|
||||
|
||||
export type ExperimentalPersistentPtyGetInput = { readonly ptyID: Pty.ID }
|
||||
export type ExperimentalPersistentPtyGetOutput = {
|
||||
readonly id: Pty.ID
|
||||
@@ -1775,6 +1788,7 @@ export interface ExperimentalApi<E = never> {
|
||||
readonly list: ExperimentalPersistentPtyListOperation<E>
|
||||
readonly create: ExperimentalPersistentPtyCreateOperation<E>
|
||||
readonly shutdown: ExperimentalPersistentPtyShutdownOperation<E>
|
||||
readonly handoff: ExperimentalPersistentPtyHandoffOperation<E>
|
||||
readonly get: ExperimentalPersistentPtyGetOperation<E>
|
||||
readonly update: ExperimentalPersistentPtyUpdateOperation<E>
|
||||
readonly snapshot: ExperimentalPersistentPtySnapshotOperation<E>
|
||||
|
||||
@@ -203,6 +203,7 @@ import type {
|
||||
ExperimentalPersistentPtyCreateInput,
|
||||
ExperimentalPersistentPtyCreateOutput,
|
||||
ExperimentalPersistentPtyShutdownOutput,
|
||||
ExperimentalPersistentPtyHandoffOutput,
|
||||
ExperimentalPersistentPtyGetInput,
|
||||
ExperimentalPersistentPtyGetOutput,
|
||||
ExperimentalPersistentPtyUpdateInput,
|
||||
@@ -1265,6 +1266,11 @@ const EndpointExperimentalPersistentPtyShutdown = (raw: RawClient["server.experi
|
||||
raw["persistentPty.shutdown"]({}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointExperimentalPersistentPtyHandoff = (raw: RawClient["server.experimental"]) => () =>
|
||||
preserveEffect<ExperimentalPersistentPtyHandoffOutput>()(
|
||||
raw["persistentPty.handoff"]({}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointExperimentalPersistentPtyGet =
|
||||
(raw: RawClient["server.experimental"]) => (input: ExperimentalPersistentPtyGetInput) =>
|
||||
preserveEffect<ExperimentalPersistentPtyGetOutput>()(
|
||||
@@ -1318,6 +1324,7 @@ const adaptGroupExperimental = (raw: RawClient["server.experimental"]) => ({
|
||||
list: EndpointExperimentalPersistentPtyList(raw),
|
||||
create: EndpointExperimentalPersistentPtyCreate(raw),
|
||||
shutdown: EndpointExperimentalPersistentPtyShutdown(raw),
|
||||
handoff: EndpointExperimentalPersistentPtyHandoff(raw),
|
||||
get: EndpointExperimentalPersistentPtyGet(raw),
|
||||
update: EndpointExperimentalPersistentPtyUpdate(raw),
|
||||
snapshot: EndpointExperimentalPersistentPtySnapshot(raw),
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "../service-contender.js"
|
||||
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
|
||||
import { matchesVersion } from "../service-version.js"
|
||||
import { PtyHandoff } from "../pty-handoff.js"
|
||||
|
||||
export * from "../service.js"
|
||||
/** Contents of the local service registration file. */
|
||||
@@ -65,9 +66,10 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
const spawnContender = Effect.gen(function* () {
|
||||
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
|
||||
if (command === undefined) return yield* Effect.fail(new Error("Missing service command"))
|
||||
const env = yield* Effect.tryPromise(() => PtyHandoff.environment(options.file ?? fallback(), options.env))
|
||||
return yield* Effect.try({
|
||||
try: () => {
|
||||
return spawnServiceContender(command, args, options.env)
|
||||
return spawnServiceContender(command, args, env)
|
||||
},
|
||||
catch: (cause) => new Error("Failed to start server", { cause }),
|
||||
})
|
||||
@@ -83,6 +85,8 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
}
|
||||
if (timeouts.count >= 3) {
|
||||
yield* announce("missing")
|
||||
yield* Effect.logWarning("Background service is unresponsive; recovery cannot preserve persistent terminals")
|
||||
yield* Effect.tryPromise(() => PtyHandoff.clear(options.file ?? fallback()))
|
||||
yield* terminate(info, options, timing)
|
||||
timeouts = undefined
|
||||
lastSpawn = Date.now() - spawnDelay
|
||||
@@ -91,11 +95,23 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
if (service !== undefined) {
|
||||
spawnDelay = timing.spawnDelay
|
||||
const compatible = !service.legacy && matchesVersion(service.version, options)
|
||||
if (compatible && service.state === "ready") return Option.some(service)
|
||||
if (compatible && service.state === "ready") {
|
||||
yield* Effect.tryPromise(() => PtyHandoff.complete(options.file ?? fallback(), service.info))
|
||||
return Option.some(service)
|
||||
}
|
||||
if (compatible && service.state === "failed")
|
||||
return yield* Effect.fail(new Error("Background service failed to start"))
|
||||
if (compatible) return Option.none<LocalService>()
|
||||
yield* announce("version-mismatch", service.version)
|
||||
if (!service.legacy && service.state === "ready")
|
||||
yield* Effect.tryPromise(() =>
|
||||
PtyHandoff.prepare(options.file ?? fallback(), service.info, timing.requestTimeout),
|
||||
)
|
||||
else {
|
||||
if (!service.legacy)
|
||||
yield* Effect.logWarning("Background service is not ready; replacement cannot preserve persistent terminals")
|
||||
yield* Effect.tryPromise(() => PtyHandoff.clear(options.file ?? fallback()))
|
||||
}
|
||||
yield* terminate(service.info, options, timing).pipe(Effect.ignore)
|
||||
lastSpawn = 0
|
||||
return Option.none<LocalService>()
|
||||
@@ -129,6 +145,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
|
||||
/** Stop the registered local service. */
|
||||
export const stop = Effect.fn("service.stop")(function* (options: StopOptions = {}) {
|
||||
yield* Effect.tryPromise(() => PtyHandoff.clear(options.file ?? fallback()))
|
||||
const info = yield* read(options.file)
|
||||
if (info !== undefined) yield* terminate(info, options, defaultEnsureTiming)
|
||||
})
|
||||
|
||||
@@ -199,6 +199,7 @@ import type {
|
||||
ExperimentalPersistentPtyCreateInput,
|
||||
ExperimentalPersistentPtyCreateOutput,
|
||||
ExperimentalPersistentPtyShutdownOutput,
|
||||
ExperimentalPersistentPtyHandoffOutput,
|
||||
ExperimentalPersistentPtyGetInput,
|
||||
ExperimentalPersistentPtyGetOutput,
|
||||
ExperimentalPersistentPtyUpdateInput,
|
||||
@@ -1723,6 +1724,17 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
handoff: (requestOptions?: RequestOptions) =>
|
||||
request<ExperimentalPersistentPtyHandoffOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/experimental/persistent-pty/handoff`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [503, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
get: (input: ExperimentalPersistentPtyGetInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: ExperimentalPersistentPtyGetOutput }>(
|
||||
{
|
||||
|
||||
@@ -376,6 +376,8 @@ export type SessionStatus =
|
||||
|
||||
export type PtyTicketConnectToken = { ticket: string; expires_in: number }
|
||||
|
||||
export type PersistentPtyHandoff = { directory: string; instanceID: string; ticket: string; expiresAt: number }
|
||||
|
||||
export type ShellInfo1 = {
|
||||
id: string
|
||||
status: "running" | "exited" | "timeout" | "killed"
|
||||
@@ -5753,50 +5755,50 @@ export type ExperimentalPersistentPtyListOutput = { data: Array<PersistentPtyInf
|
||||
|
||||
export type ExperimentalPersistentPtyCreateInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly command: {
|
||||
readonly command: string
|
||||
readonly command?: {
|
||||
readonly command?: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly cwd?: string
|
||||
readonly title: string
|
||||
readonly env: { readonly [x: string]: string }
|
||||
readonly size?: { readonly cols: number; readonly rows: number }
|
||||
}["command"]
|
||||
readonly args: {
|
||||
readonly command: string
|
||||
readonly command?: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly cwd?: string
|
||||
readonly title: string
|
||||
readonly env: { readonly [x: string]: string }
|
||||
readonly size?: { readonly cols: number; readonly rows: number }
|
||||
}["args"]
|
||||
readonly cwd: {
|
||||
readonly command: string
|
||||
readonly cwd?: {
|
||||
readonly command?: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly cwd?: string
|
||||
readonly title: string
|
||||
readonly env: { readonly [x: string]: string }
|
||||
readonly size?: { readonly cols: number; readonly rows: number }
|
||||
}["cwd"]
|
||||
readonly title: {
|
||||
readonly command: string
|
||||
readonly command?: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly cwd?: string
|
||||
readonly title: string
|
||||
readonly env: { readonly [x: string]: string }
|
||||
readonly size?: { readonly cols: number; readonly rows: number }
|
||||
}["title"]
|
||||
readonly env: {
|
||||
readonly command: string
|
||||
readonly command?: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly cwd?: string
|
||||
readonly title: string
|
||||
readonly env: { readonly [x: string]: string }
|
||||
readonly size?: { readonly cols: number; readonly rows: number }
|
||||
}["env"]
|
||||
readonly size?: {
|
||||
readonly command: string
|
||||
readonly command?: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly cwd?: string
|
||||
readonly title: string
|
||||
readonly env: { readonly [x: string]: string }
|
||||
readonly size?: { readonly cols: number; readonly rows: number }
|
||||
@@ -5807,6 +5809,8 @@ export type ExperimentalPersistentPtyCreateOutput = { data: PersistentPtyInfo }[
|
||||
|
||||
export type ExperimentalPersistentPtyShutdownOutput = void
|
||||
|
||||
export type ExperimentalPersistentPtyHandoffOutput = { handoff: PersistentPtyHandoff | null }
|
||||
|
||||
export type ExperimentalPersistentPtyGetInput = { readonly ptyID: { readonly ptyID: string }["ptyID"] }
|
||||
|
||||
export type ExperimentalPersistentPtyGetOutput = { data: PersistentPtyInfo }["data"]
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "../service-contender.js"
|
||||
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
|
||||
import { matchesVersion } from "../service-version.js"
|
||||
import { PtyHandoff } from "../pty-handoff.js"
|
||||
import type { ServiceHealth } from "./generated/types.js"
|
||||
|
||||
export * from "../service.js"
|
||||
@@ -43,11 +44,11 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
announced = true
|
||||
options.onStart?.(reason, previousVersion)
|
||||
}
|
||||
const spawnContender = () => {
|
||||
const spawnContender = async () => {
|
||||
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
|
||||
if (command === undefined) throw new Error("Missing service command")
|
||||
try {
|
||||
return spawnServiceContender(command, args, options.env)
|
||||
return spawnServiceContender(command, args, await PtyHandoff.environment(options.file ?? fallback(), options.env))
|
||||
} catch (cause) {
|
||||
throw new Error("Failed to start server", { cause })
|
||||
}
|
||||
@@ -64,6 +65,8 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
}
|
||||
if (timeouts.count >= 3) {
|
||||
announce("missing")
|
||||
console.warn("Background service is unresponsive; recovery cannot preserve persistent terminals")
|
||||
await PtyHandoff.clear(options.file ?? fallback())
|
||||
await terminate(registration.info, options, timing)
|
||||
timeouts = undefined
|
||||
lastSpawn = Date.now() - spawnDelay
|
||||
@@ -74,10 +77,20 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
spawnDelay = timing.spawnDelay
|
||||
const service = registration.service
|
||||
const compatible = !service.legacy && matchesVersion(service.version, options)
|
||||
if (compatible && service.state === "ready") return service.endpoint
|
||||
if (compatible && service.state === "ready") {
|
||||
await PtyHandoff.complete(options.file ?? fallback(), service.info)
|
||||
return service.endpoint
|
||||
}
|
||||
if (compatible && service.state === "failed") throw new Error("Background service failed to start")
|
||||
if (!compatible) {
|
||||
announce("version-mismatch", service.version)
|
||||
if (!service.legacy && service.state === "ready")
|
||||
await PtyHandoff.prepare(options.file ?? fallback(), service.info, timing.requestTimeout)
|
||||
else {
|
||||
if (!service.legacy)
|
||||
console.warn("Background service is not ready; replacement cannot preserve persistent terminals")
|
||||
await PtyHandoff.clear(options.file ?? fallback())
|
||||
}
|
||||
await terminate(service.info, options, timing).catch(() => undefined)
|
||||
lastSpawn = 0
|
||||
}
|
||||
@@ -93,7 +106,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
// Keep one candidate plus one lock probe so a pre-lock stall cannot block recovery.
|
||||
if (contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
|
||||
announce("missing")
|
||||
contenders.add(spawnContender())
|
||||
contenders.add(await spawnContender())
|
||||
lastSpawn = Date.now()
|
||||
}
|
||||
}
|
||||
@@ -106,6 +119,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
|
||||
/** Stop the registered local service. */
|
||||
export async function stop(options: StopOptions = {}) {
|
||||
await PtyHandoff.clear(options.file ?? fallback())
|
||||
const info = await read(options.file)
|
||||
if (info !== undefined) await terminate(info, options, defaultEnsureTiming)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
export * as PtyHandoff from "./pty-handoff.js"
|
||||
|
||||
import type { PersistentPty } from "@opencode-ai/schema/persistent-pty"
|
||||
import { readFile, rename, rm, writeFile } from "node:fs/promises"
|
||||
import type { Info } from "./service.js"
|
||||
|
||||
type Sidecar = {
|
||||
readonly source: Pick<Info, "id" | "pid" | "url">
|
||||
readonly handoff: PersistentPty.Handoff | null
|
||||
readonly expiresAt: number
|
||||
}
|
||||
|
||||
/** Publish the ticket before stopping its owner so every replacement contender can adopt it. */
|
||||
export async function prepare(file: string, info: Info, timeout: number) {
|
||||
const existing = await read(file)
|
||||
if (existing !== undefined && existing.expiresAt > Date.now() && same(existing.source, info)) return
|
||||
const { ClientError, OpenCode } = await import("./promise/index.js")
|
||||
const client = OpenCode.make({
|
||||
baseUrl: info.url,
|
||||
headers:
|
||||
info.password === undefined
|
||||
? undefined
|
||||
: { authorization: "Basic " + Buffer.from(`opencode:${info.password}`).toString("base64") },
|
||||
})
|
||||
const missing = (error: unknown) =>
|
||||
error instanceof ClientError &&
|
||||
error.reason === "UnexpectedStatus" &&
|
||||
typeof error.cause === "object" &&
|
||||
error.cause !== null &&
|
||||
"status" in error.cause &&
|
||||
error.cause.status === 404
|
||||
const result = await client.experimental.persistentPty.handoff({ signal: AbortSignal.timeout(timeout) }).then(
|
||||
(value) => ({ value }),
|
||||
(cause: unknown) => ({ cause }),
|
||||
)
|
||||
if ("cause" in result) {
|
||||
// Another caller may already have prepared and stopped this server.
|
||||
const concurrent = await read(file)
|
||||
if (concurrent !== undefined && concurrent.expiresAt > Date.now() && same(concurrent.source, info)) return
|
||||
if (!missing(result.cause))
|
||||
throw new Error("Failed to prepare persistent terminals for service replacement", { cause: result.cause })
|
||||
console.warn("Background service cannot hand off persistent terminals; shutting them down before replacement")
|
||||
await client.experimental.persistentPty
|
||||
.shutdown({ signal: AbortSignal.timeout(timeout) })
|
||||
.catch((cause: unknown) => {
|
||||
if (missing(cause)) return
|
||||
throw new Error("Failed to shut down persistent terminals before service replacement", { cause })
|
||||
})
|
||||
await publish(file, info, null)
|
||||
return
|
||||
}
|
||||
const body: unknown = result.value
|
||||
if (typeof body !== "object" || body === null || !("handoff" in body))
|
||||
throw new Error("Invalid persistent terminal handoff response")
|
||||
if (body.handoff === null) {
|
||||
await publish(file, info, null)
|
||||
return
|
||||
}
|
||||
if (!isHandoff(body.handoff) || body.handoff.expiresAt <= Date.now())
|
||||
throw new Error("Invalid or expired persistent terminal handoff")
|
||||
await publish(file, info, body.handoff)
|
||||
}
|
||||
|
||||
async function publish(file: string, info: Info, handoff: PersistentPty.Handoff | null) {
|
||||
const temporary = `${file}.pty-handoff.${crypto.randomUUID()}.tmp`
|
||||
await writeFile(
|
||||
temporary,
|
||||
JSON.stringify({
|
||||
source: { id: info.id, pid: info.pid, url: info.url },
|
||||
handoff,
|
||||
expiresAt: handoff?.expiresAt ?? Date.now() + 30_000,
|
||||
} satisfies Sidecar),
|
||||
{ mode: 0o600, flag: "wx" },
|
||||
)
|
||||
await rename(temporary, file + ".pty-handoff").finally(() => rm(temporary, { force: true }))
|
||||
}
|
||||
|
||||
export async function environment(file: string, env?: Readonly<Record<string, string>>) {
|
||||
const record = await read(file)
|
||||
const current: Info | undefined = await readFile(file, "utf8")
|
||||
.then((text) => JSON.parse(text))
|
||||
.catch(() => undefined)
|
||||
const handoff =
|
||||
record !== undefined && record.expiresAt > Date.now() && (current === undefined || same(record.source, current))
|
||||
? record.handoff
|
||||
: undefined
|
||||
return { ...env, OPENCODE_PTY_HANDOFF: handoff == null ? undefined : JSON.stringify(handoff) }
|
||||
}
|
||||
|
||||
export async function complete(file: string, info: Info) {
|
||||
const record = await read(file)
|
||||
if (record !== undefined && !same(record.source, info)) await clear(file)
|
||||
}
|
||||
|
||||
export async function clear(file: string) {
|
||||
await rm(file + ".pty-handoff", { force: true })
|
||||
}
|
||||
|
||||
async function read(file: string): Promise<Sidecar | undefined> {
|
||||
const value: unknown = await readFile(file + ".pty-handoff", "utf8")
|
||||
.then((text) => JSON.parse(text))
|
||||
.catch(() => undefined)
|
||||
if (typeof value !== "object" || value === null || !("source" in value) || !("handoff" in value)) return
|
||||
if (typeof value.source !== "object" || value.source === null) return
|
||||
if (!("pid" in value.source) || typeof value.source.pid !== "number") return
|
||||
if (!("url" in value.source) || typeof value.source.url !== "string") return
|
||||
if ("id" in value.source && typeof value.source.id !== "string") return
|
||||
if (value.handoff !== null && !isHandoff(value.handoff)) return
|
||||
if (!("expiresAt" in value) || typeof value.expiresAt !== "number" || !Number.isFinite(value.expiresAt)) return
|
||||
return {
|
||||
source: {
|
||||
id: "id" in value.source && typeof value.source.id === "string" ? value.source.id : undefined,
|
||||
pid: value.source.pid,
|
||||
url: value.source.url,
|
||||
},
|
||||
handoff: value.handoff,
|
||||
expiresAt: value.expiresAt,
|
||||
}
|
||||
}
|
||||
|
||||
function same(left: Sidecar["source"], right: Info) {
|
||||
return left.id === right.id && left.pid === right.pid && left.url === right.url
|
||||
}
|
||||
|
||||
function isHandoff(value: unknown): value is PersistentPty.Handoff {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"directory" in value &&
|
||||
typeof value.directory === "string" &&
|
||||
"instanceID" in value &&
|
||||
typeof value.instanceID === "string" &&
|
||||
"ticket" in value &&
|
||||
typeof value.ticket === "string" &&
|
||||
"expiresAt" in value &&
|
||||
typeof value.expiresAt === "number" &&
|
||||
Number.isFinite(value.expiresAt)
|
||||
)
|
||||
}
|
||||
@@ -13,7 +13,7 @@ const stderrLimit = 8 * 1024
|
||||
export function spawnServiceContender(
|
||||
command: string,
|
||||
args: ReadonlyArray<string>,
|
||||
env?: Readonly<Record<string, string>>,
|
||||
env?: Readonly<Record<string, string | undefined>>,
|
||||
): ServiceContender {
|
||||
const child = spawn(command, args, {
|
||||
detached: true,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { waitForExit } from "./service-timing"
|
||||
|
||||
export async function serviceFixture() {
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-client-service-"))
|
||||
const registration = join(directory, "service.json")
|
||||
const processes: Bun.Subprocess[] = []
|
||||
const pids = new Set<number>()
|
||||
const command = (mode: string, ...args: string[]) => [
|
||||
process.execPath,
|
||||
join(import.meta.dir, "service.ts"),
|
||||
registration,
|
||||
mode,
|
||||
...args,
|
||||
]
|
||||
|
||||
return {
|
||||
directory,
|
||||
registration,
|
||||
command,
|
||||
spawn(mode: string, ...args: string[]) {
|
||||
const subprocess = Bun.spawn(command(mode, ...args), { stdout: "ignore", stderr: "inherit" })
|
||||
processes.push(subprocess)
|
||||
return subprocess
|
||||
},
|
||||
// Service.ensure detaches contenders; track the elected process before asserting.
|
||||
track(pid: number) {
|
||||
pids.add(pid)
|
||||
},
|
||||
async waitForFile(file = registration) {
|
||||
for (let attempt = 0; attempt < 600; attempt++) {
|
||||
if (await Bun.file(file).exists()) return
|
||||
await Bun.sleep(5)
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${file}`)
|
||||
},
|
||||
async [Symbol.asyncDispose]() {
|
||||
await Promise.all([
|
||||
...processes.map(async (subprocess) => {
|
||||
subprocess.kill("SIGTERM")
|
||||
await subprocess.exited
|
||||
}),
|
||||
...[...pids].map(async (pid) => {
|
||||
try {
|
||||
process.kill(pid, "SIGTERM")
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error && "code" in error && error.code === "ESRCH")) throw error
|
||||
}
|
||||
await waitForExit(pid)
|
||||
}),
|
||||
]).finally(() => rm(directory, { recursive: true, force: true }))
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ const timing = {
|
||||
stopPollInterval: 5,
|
||||
}
|
||||
|
||||
export function accelerate<A extends object, B>(ensure: (options: A) => B) {
|
||||
export function accelerate<A extends object, B>(ensure: (options?: A) => B) {
|
||||
return (options: A) => ensure(withEnsureTiming(options, timing))
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,10 @@ if (mode === "record-start") {
|
||||
await writeFile(registration + ".started", "")
|
||||
process.exit(1)
|
||||
}
|
||||
if (mode === "environment")
|
||||
if (mode === "environment") {
|
||||
await writeFile(registration + ".environment", process.env.OPENCODE_SERVICE_ENV_TEST ?? "")
|
||||
await writeFile(registration + ".handoff", process.env.OPENCODE_PTY_HANDOFF ?? "null")
|
||||
}
|
||||
if (mode === "signal") process.kill(process.pid, process.platform === "win32" ? "SIGTERM" : "SIGKILL")
|
||||
|
||||
if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated" || mode === "coordinated-failed-loser") {
|
||||
@@ -30,14 +32,26 @@ if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated" ||
|
||||
|
||||
let requests = 0
|
||||
let version = "test"
|
||||
if (mode === "old") version = "old"
|
||||
if (mode === "old" || mode === "handoff") version = "old"
|
||||
if (mode === "incompatible") version = "1.9.0"
|
||||
if (mode === "compatible" || mode === "delayed-compatible") version = "2.1.0-next.1"
|
||||
const id = crypto.randomUUID()
|
||||
const handoff = {
|
||||
directory: registration + ".daemon",
|
||||
instanceID: crypto.randomUUID(),
|
||||
ticket: crypto.randomUUID(),
|
||||
expiresAt: Date.now() + 30_000,
|
||||
}
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(request) {
|
||||
const pathname = new URL(request.url).pathname
|
||||
if (pathname === "/api/experimental/persistent-pty/handoff" && mode === "handoff") {
|
||||
if (request.method !== "POST" || request.headers.get("authorization") !== "Basic " + btoa("opencode:private"))
|
||||
return new Response(null, { status: 401 })
|
||||
await writeFile(registration + ".prepared", JSON.stringify(handoff))
|
||||
return Response.json({ handoff })
|
||||
}
|
||||
if (pathname !== "/api/health") return new Response(null, { status: 404 })
|
||||
requests += 1
|
||||
if (mode === "starting") await writeFile(registration + ".health-request", "")
|
||||
@@ -54,8 +68,7 @@ const server = Bun.serve({
|
||||
if (mode === "starting" && !(await Bun.file(registration + ".release").exists()))
|
||||
return Response.json({ healthy: true, version, pid: process.pid }, { status: 503 })
|
||||
if (mode === "failed-owner") return Response.json({ healthy: true, version, pid: process.pid }, { status: 500 })
|
||||
if (mode === "starting" || mode === "graceful")
|
||||
return Response.json({ healthy: true, version, pid: process.pid })
|
||||
if (mode === "starting" || mode === "graceful") return Response.json({ healthy: true, version, pid: process.pid })
|
||||
return Response.json({ healthy: true, version, pid: process.pid })
|
||||
},
|
||||
})
|
||||
@@ -67,6 +80,7 @@ await writeFile(
|
||||
version: mode === "legacy" ? undefined : version,
|
||||
url: server.url.toString(),
|
||||
pid: process.pid,
|
||||
password: "private",
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
)
|
||||
|
||||
@@ -1,23 +1,15 @@
|
||||
import { afterEach, expect, test } from "bun:test"
|
||||
import { mkdtemp, rm } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { expect, test } from "bun:test"
|
||||
import { Service, type EnsureReason } from "../src/promise/service"
|
||||
import { accelerate, waitForExit } from "./fixture/service-timing"
|
||||
import { serviceFixture } from "./fixture/service-fixture"
|
||||
import { accelerate } from "./fixture/service-timing"
|
||||
|
||||
const fixture = join(import.meta.dir, "fixture/service.ts")
|
||||
const ensure = accelerate(Service.ensure)
|
||||
const processes: Bun.Subprocess[] = []
|
||||
const directories: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
processes.forEach((process) => process.kill("SIGTERM"))
|
||||
await Promise.all(processes.splice(0).map((process) => process.exited))
|
||||
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
test("discovers a registered service", async () => {
|
||||
const registration = await setup("graceful")
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
fixture.spawn("graceful")
|
||||
await fixture.waitForFile()
|
||||
|
||||
expect(await Service.discover({ file: registration, version: "test" })).toEqual(
|
||||
expect.objectContaining({ url: expect.stringMatching(/^http:\/\//) }),
|
||||
@@ -26,7 +18,10 @@ test("discovers a registered service", async () => {
|
||||
})
|
||||
|
||||
test("discovers a compatible registered service", async () => {
|
||||
const registration = await setup("compatible")
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
fixture.spawn("compatible")
|
||||
await fixture.waitForFile()
|
||||
|
||||
expect(await Service.discover({ file: registration, version: "2.1.0" })).toBeUndefined()
|
||||
expect(await Service.discover({ file: registration, version: "2.1.0-next.1" })).toEqual(
|
||||
@@ -39,66 +34,77 @@ test("discovers a compatible registered service", async () => {
|
||||
})
|
||||
|
||||
test("ensures a missing service with native promises", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
const starts: EnsureReason[] = []
|
||||
|
||||
const endpoint = await ensure({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [process.execPath, fixture, registration, "coordinated"],
|
||||
command: fixture.command("coordinated"),
|
||||
onStart: (reason) => starts.push(reason),
|
||||
})
|
||||
const info = await Bun.file(registration).json()
|
||||
try {
|
||||
expect(endpoint.url).toBe(info.url)
|
||||
expect(starts).toEqual(["missing"])
|
||||
} finally {
|
||||
process.kill(info.pid, "SIGTERM")
|
||||
await waitForExit(info.pid)
|
||||
}
|
||||
fixture.track(info.pid)
|
||||
|
||||
expect(endpoint.url).toBe(info.url)
|
||||
expect(starts).toEqual(["missing"])
|
||||
})
|
||||
|
||||
test("adds configured environment variables with native promises", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
const endpoint = await ensure({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [process.execPath, fixture, registration, "environment"],
|
||||
command: fixture.command("environment"),
|
||||
env: { OPENCODE_SERVICE_ENV_TEST: "configured" },
|
||||
})
|
||||
const info = await Bun.file(registration).json()
|
||||
fixture.track(info.pid)
|
||||
|
||||
try {
|
||||
expect(endpoint.url).toBe(info.url)
|
||||
expect(await Bun.file(registration + ".environment").text()).toBe("configured")
|
||||
} finally {
|
||||
process.kill(info.pid, "SIGTERM")
|
||||
await waitForExit(info.pid)
|
||||
}
|
||||
expect(endpoint.url).toBe(info.url)
|
||||
expect(await Bun.file(registration + ".environment").text()).toBe("configured")
|
||||
})
|
||||
|
||||
test("passes the prepared handoff to the replacement server", async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
fixture.spawn("handoff")
|
||||
await fixture.waitForFile()
|
||||
await ensure({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: fixture.command("environment"),
|
||||
env: { OPENCODE_PTY_HANDOFF: "must-not-inherit" },
|
||||
})
|
||||
const replacement = await Bun.file(registration).json()
|
||||
fixture.track(replacement.pid)
|
||||
|
||||
expect(await Bun.file(registration + ".handoff").json()).toEqual(await Bun.file(registration + ".prepared").json())
|
||||
expect(await Bun.file(registration + ".pty-handoff").exists()).toBe(false)
|
||||
})
|
||||
|
||||
test("waits for a live contender when another native contender fails", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
|
||||
const endpoint = await ensure({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [process.execPath, fixture, registration, "coordinated-failed-loser", "300"],
|
||||
command: fixture.command("coordinated-failed-loser", "300"),
|
||||
})
|
||||
const info = await Bun.file(registration).json()
|
||||
try {
|
||||
expect(endpoint.url).toBe(info.url)
|
||||
} finally {
|
||||
process.kill(info.pid, "SIGTERM")
|
||||
await waitForExit(info.pid)
|
||||
}
|
||||
fixture.track(info.pid)
|
||||
|
||||
expect(endpoint.url).toBe(info.url)
|
||||
})
|
||||
|
||||
test("reports a failed registered service", async () => {
|
||||
const registration = await setup("failed-owner")
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
fixture.spawn("failed-owner")
|
||||
await fixture.waitForFile()
|
||||
|
||||
await expect(ensure({ file: registration, version: "test", command: [] })).rejects.toThrow(
|
||||
"Background service failed to start",
|
||||
@@ -106,12 +112,12 @@ test("reports a failed registered service", async () => {
|
||||
})
|
||||
|
||||
test("reports a bounded contender stderr tail with native promises", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
const error = await Service.ensure({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [process.execPath, fixture, registration, "stderr-failed"],
|
||||
command: fixture.command("stderr-failed"),
|
||||
}).catch((error: unknown) => error)
|
||||
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
@@ -121,58 +127,34 @@ test("reports a bounded contender stderr tail with native promises", async () =>
|
||||
}, 10_000)
|
||||
|
||||
test("evicts an unresponsive registered service before starting its replacement", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
const existing = Bun.spawn([process.execPath, fixture, registration, "hanging"], {
|
||||
stdout: "ignore",
|
||||
stderr: "inherit",
|
||||
})
|
||||
processes.push(existing)
|
||||
await waitForFile(registration)
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
const existing = fixture.spawn("hanging")
|
||||
await fixture.waitForFile()
|
||||
const original = await Bun.file(registration).json()
|
||||
|
||||
const endpoint = await ensure({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [process.execPath, fixture, registration, "delayed", "10"],
|
||||
command: fixture.command("delayed", "10"),
|
||||
})
|
||||
const replacement = await Bun.file(registration).json()
|
||||
fixture.track(replacement.pid)
|
||||
|
||||
expect((await Bun.file(registration + ".requests").text()).trim().split("\n")).toHaveLength(3)
|
||||
expect(await existing.exited).toBe(0)
|
||||
expect(replacement.pid).not.toBe(original.pid)
|
||||
expect(endpoint.url).toBe(replacement.url)
|
||||
process.kill(replacement.pid, "SIGTERM")
|
||||
await waitForExit(replacement.pid)
|
||||
})
|
||||
|
||||
test("signals the registered service process", async () => {
|
||||
const registration = await setup("graceful")
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
fixture.spawn("graceful")
|
||||
await fixture.waitForFile()
|
||||
|
||||
await Service.stop({ file: registration })
|
||||
|
||||
expect(await Bun.file(registration + ".signal").text()).toBe("SIGTERM")
|
||||
expect(await Bun.file(registration).exists()).toBe(false)
|
||||
})
|
||||
|
||||
async function setup(mode: string) {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
processes.push(Bun.spawn([process.execPath, fixture, registration, mode], { stdout: "ignore", stderr: "inherit" }))
|
||||
await waitForFile(registration)
|
||||
return registration
|
||||
}
|
||||
|
||||
async function temp() {
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-promise-service-"))
|
||||
directories.push(directory)
|
||||
return directory
|
||||
}
|
||||
|
||||
async function waitForFile(file: string) {
|
||||
for (let attempt = 0; attempt < 600; attempt++) {
|
||||
if (await Bun.file(file).exists()) return
|
||||
await Bun.sleep(5)
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${file}`)
|
||||
}
|
||||
|
||||
@@ -1,28 +1,18 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { afterEach, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { expect, test } from "bun:test"
|
||||
import { Effect, FileSystem } from "effect"
|
||||
import { writeFile } from "node:fs/promises"
|
||||
import { Service, type EnsureReason } from "../src/effect/service"
|
||||
import { accelerate, waitForExit } from "./fixture/service-timing"
|
||||
import { serviceFixture } from "./fixture/service-fixture"
|
||||
import { accelerate } from "./fixture/service-timing"
|
||||
|
||||
const fixture = join(import.meta.dir, "fixture/service.ts")
|
||||
const ensure = accelerate(Service.ensure)
|
||||
const processes: Bun.Subprocess[] = []
|
||||
const directories: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
processes.forEach((process) => process.kill("SIGTERM"))
|
||||
await Promise.all(processes.splice(0).map((process) => process.exited))
|
||||
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
test("a concurrent same-version start cannot invalidate a resolved endpoint", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
spawn(registration, "modern")
|
||||
await waitForFile(registration)
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
fixture.spawn("modern")
|
||||
await fixture.waitForFile()
|
||||
const original = await Bun.file(registration).json()
|
||||
|
||||
const starts: EnsureReason[] = []
|
||||
@@ -34,7 +24,7 @@ test("a concurrent same-version start cannot invalidate a resolved endpoint", as
|
||||
onStart: (reason) => starts.push(reason),
|
||||
}),
|
||||
)
|
||||
await waitForFile(registration + ".first-request")
|
||||
await fixture.waitForFile(registration + ".first-request")
|
||||
|
||||
const resolved = await run(ensure({ file: registration, version: "test" }))
|
||||
expect(resolved.url).toBe(original.url)
|
||||
@@ -48,10 +38,10 @@ test("a concurrent same-version start cannot invalidate a resolved endpoint", as
|
||||
})
|
||||
|
||||
test("reuses a compatible registered service", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
const existing = spawn(registration, "compatible")
|
||||
await waitForFile(registration)
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
const existing = fixture.spawn("compatible")
|
||||
await fixture.waitForFile()
|
||||
|
||||
const starts: EnsureReason[] = []
|
||||
const endpoint = await run(
|
||||
@@ -69,70 +59,65 @@ test("reuses a compatible registered service", async () => {
|
||||
})
|
||||
|
||||
test("adds configured environment variables when starting a service", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
const endpoint = await run(
|
||||
ensure({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [process.execPath, fixture, registration, "environment"],
|
||||
command: fixture.command("environment"),
|
||||
env: { OPENCODE_SERVICE_ENV_TEST: "configured" },
|
||||
}),
|
||||
)
|
||||
const info = await Bun.file(registration).json()
|
||||
fixture.track(info.pid)
|
||||
|
||||
try {
|
||||
expect(endpoint.url).toBe(info.url)
|
||||
expect(await Bun.file(registration + ".environment").text()).toBe("configured")
|
||||
} finally {
|
||||
process.kill(info.pid, "SIGTERM")
|
||||
await waitForExit(info.pid)
|
||||
}
|
||||
expect(endpoint.url).toBe(info.url)
|
||||
expect(await Bun.file(registration + ".environment").text()).toBe("configured")
|
||||
})
|
||||
|
||||
test("replaces an incompatible registered service", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
const existing = spawn(registration, "incompatible")
|
||||
await waitForFile(registration)
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
const existing = fixture.spawn("incompatible")
|
||||
await fixture.waitForFile()
|
||||
|
||||
const starts: EnsureReason[] = []
|
||||
const endpoint = await run(
|
||||
ensure({
|
||||
file: registration,
|
||||
version: (version) => version.startsWith("2."),
|
||||
command: [process.execPath, fixture, registration, "delayed-compatible", "10"],
|
||||
command: fixture.command("delayed-compatible", "10"),
|
||||
onStart: (reason) => starts.push(reason),
|
||||
}),
|
||||
)
|
||||
const replacement = await Bun.file(registration).json()
|
||||
fixture.track(replacement.pid)
|
||||
|
||||
expect(await existing.exited).toBe(0)
|
||||
expect(replacement.version).toBe("2.1.0-next.1")
|
||||
expect(endpoint.url).toBe(replacement.url)
|
||||
expect(starts).toEqual(["version-mismatch"])
|
||||
process.kill(replacement.pid, "SIGTERM")
|
||||
await waitForExit(replacement.pid)
|
||||
})
|
||||
|
||||
test("waits for a registered service to finish starting", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
const process = spawn(registration, "starting")
|
||||
await waitForFile(registration)
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
const process = fixture.spawn("starting")
|
||||
await fixture.waitForFile()
|
||||
const result = run(ensure({ file: registration, version: "test", command: [] }))
|
||||
|
||||
await waitForFile(registration + ".health-request")
|
||||
await fixture.waitForFile(registration + ".health-request")
|
||||
expect(process.exitCode).toBe(null)
|
||||
await writeFile(registration + ".release", "")
|
||||
expect((await result).url).toBe((await Bun.file(registration).json()).url)
|
||||
})
|
||||
|
||||
test("reports a failed registered service without spawning", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
const process = spawn(registration, "failed-owner")
|
||||
await waitForFile(registration)
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
const process = fixture.spawn("failed-owner")
|
||||
await fixture.waitForFile()
|
||||
|
||||
await expect(run(ensure({ file: registration, version: "test", command: [] }))).rejects.toThrow(
|
||||
"Background service failed to start",
|
||||
@@ -141,35 +126,34 @@ test("reports a failed registered service without spawning", async () => {
|
||||
})
|
||||
|
||||
test("evicts an unresponsive registered service before starting its replacement", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
const existing = spawn(registration, "hanging")
|
||||
await waitForFile(registration)
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
const existing = fixture.spawn("hanging")
|
||||
await fixture.waitForFile()
|
||||
const original = await Bun.file(registration).json()
|
||||
|
||||
const endpoint = await run(
|
||||
ensure({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [process.execPath, fixture, registration, "delayed", "10"],
|
||||
command: fixture.command("delayed", "10"),
|
||||
}),
|
||||
)
|
||||
const replacement = await Bun.file(registration).json()
|
||||
fixture.track(replacement.pid)
|
||||
|
||||
expect((await Bun.file(registration + ".requests").text()).trim().split("\n")).toHaveLength(3)
|
||||
expect(await existing.exited).toBe(0)
|
||||
expect(replacement.pid).not.toBe(original.pid)
|
||||
expect(endpoint.url).toBe(replacement.url)
|
||||
expect(await health(endpoint.url)).toEqual({ healthy: true, version: "test", pid: replacement.pid })
|
||||
process.kill(replacement.pid, "SIGTERM")
|
||||
await waitForExit(replacement.pid)
|
||||
})
|
||||
|
||||
test("signals an unresponsive registered service process", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
const process = spawn(registration, "hanging")
|
||||
await waitForFile(registration)
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
const process = fixture.spawn("hanging")
|
||||
await fixture.waitForFile()
|
||||
|
||||
await run(Service.stop({ file: registration }))
|
||||
await process.exited
|
||||
@@ -178,30 +162,29 @@ test("signals an unresponsive registered service process", async () => {
|
||||
})
|
||||
|
||||
test("signals an incompatible service before starting its replacement", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
const existing = spawn(registration, "old")
|
||||
await waitForFile(registration)
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
const existing = fixture.spawn("old")
|
||||
await fixture.waitForFile()
|
||||
const endpoint = await run(
|
||||
ensure({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [process.execPath, fixture, registration, "delayed", "10"],
|
||||
command: fixture.command("delayed", "10"),
|
||||
}),
|
||||
)
|
||||
const replacement = await Bun.file(registration).json()
|
||||
fixture.track(replacement.pid)
|
||||
|
||||
expect(await existing.exited).toBe(0)
|
||||
expect(endpoint.url).toBe(replacement.url)
|
||||
process.kill(replacement.pid, "SIGTERM")
|
||||
await waitForExit(replacement.pid)
|
||||
})
|
||||
|
||||
test("a legacy health response is still replaced", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
const existing = spawn(registration, "legacy")
|
||||
await waitForFile(registration)
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
const existing = fixture.spawn("legacy")
|
||||
await fixture.waitForFile()
|
||||
|
||||
const starts: EnsureReason[] = []
|
||||
const result = run(ensure({ file: registration, command: [], onStart: (reason) => starts.push(reason) }))
|
||||
@@ -212,67 +195,61 @@ test("a legacy health response is still replaced", async () => {
|
||||
})
|
||||
|
||||
test("waits for a slow winner while bounding lock probes", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
const endpoint = await run(
|
||||
ensure({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [process.execPath, fixture, registration, "coordinated"],
|
||||
command: fixture.command("coordinated"),
|
||||
}),
|
||||
)
|
||||
const info = await Bun.file(registration).json()
|
||||
try {
|
||||
expect(endpoint.url).toBe(info.url)
|
||||
expect(await health(endpoint.url)).toEqual({ healthy: true, version: "test", pid: info.pid })
|
||||
expect((await Bun.file(registration + ".starts").text()).trim().split("\n")).toHaveLength(2)
|
||||
} finally {
|
||||
process.kill(info.pid, "SIGTERM")
|
||||
await waitForExit(info.pid)
|
||||
}
|
||||
fixture.track(info.pid)
|
||||
|
||||
expect(endpoint.url).toBe(info.url)
|
||||
expect(await health(endpoint.url)).toEqual({ healthy: true, version: "test", pid: info.pid })
|
||||
expect((await Bun.file(registration + ".starts").text()).trim().split("\n")).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("waits for a live contender when another contender fails", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
const endpoint = await run(
|
||||
ensure({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [process.execPath, fixture, registration, "coordinated-failed-loser", "300"],
|
||||
command: fixture.command("coordinated-failed-loser", "300"),
|
||||
}),
|
||||
)
|
||||
const info = await Bun.file(registration).json()
|
||||
try {
|
||||
expect(endpoint.url).toBe(info.url)
|
||||
} finally {
|
||||
process.kill(info.pid, "SIGTERM")
|
||||
await waitForExit(info.pid)
|
||||
}
|
||||
fixture.track(info.pid)
|
||||
|
||||
expect(endpoint.url).toBe(info.url)
|
||||
})
|
||||
|
||||
test("reports a contender that fails to start", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
await expect(
|
||||
run(
|
||||
ensure({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [process.execPath, fixture, registration, "failed"],
|
||||
command: fixture.command("failed"),
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow("Server process exited with code 1")
|
||||
})
|
||||
|
||||
test("reports a bounded contender stderr tail", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
const error = await run(
|
||||
Service.ensure({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [process.execPath, fixture, registration, "stderr-failed"],
|
||||
command: fixture.command("stderr-failed"),
|
||||
}),
|
||||
).catch((error: unknown) => error)
|
||||
|
||||
@@ -283,85 +260,59 @@ test("reports a bounded contender stderr tail", async () => {
|
||||
}, 10_000)
|
||||
|
||||
test("reports a contender terminated by a signal", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
await expect(
|
||||
run(
|
||||
ensure({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [process.execPath, fixture, registration, "signal"],
|
||||
command: fixture.command("signal"),
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow(/Server process (terminated by|exited with code)/)
|
||||
})
|
||||
|
||||
test("reports a slow contender that eventually fails", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
await expect(
|
||||
run(
|
||||
ensure({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [process.execPath, fixture, registration, "delayed-failed", "500"],
|
||||
command: fixture.command("delayed-failed", "500"),
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow("Server process exited with code 1")
|
||||
})
|
||||
|
||||
test("replaces an incompatible owner that appears during startup", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
const starting = run(
|
||||
ensure({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [process.execPath, fixture, registration, "delayed", "500"],
|
||||
command: fixture.command("delayed", "500"),
|
||||
}),
|
||||
)
|
||||
await waitForFile(registration + ".starts")
|
||||
const old = spawn(registration, "old")
|
||||
await waitForFile(registration)
|
||||
await fixture.waitForFile(registration + ".starts")
|
||||
const old = fixture.spawn("old")
|
||||
await fixture.waitForFile()
|
||||
const endpoint = await starting
|
||||
const info = await Bun.file(registration).json()
|
||||
try {
|
||||
expect(endpoint.url).toBe(info.url)
|
||||
expect(info.version).toBe("test")
|
||||
await old.exited
|
||||
} finally {
|
||||
process.kill(info.pid, "SIGTERM")
|
||||
await waitForExit(info.pid)
|
||||
}
|
||||
fixture.track(info.pid)
|
||||
|
||||
expect(endpoint.url).toBe(info.url)
|
||||
expect(info.version).toBe("test")
|
||||
await old.exited
|
||||
})
|
||||
|
||||
function run<A, E>(effect: Effect.Effect<A, E>) {
|
||||
function run<A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem>) {
|
||||
return Effect.runPromise(effect.pipe(Effect.provide(NodeFileSystem.layer)))
|
||||
}
|
||||
|
||||
function spawn(registration: string, mode: string, ...args: string[]) {
|
||||
const subprocess = Bun.spawn([process.execPath, fixture, registration, mode, ...args], {
|
||||
stdout: "ignore",
|
||||
stderr: "inherit",
|
||||
})
|
||||
processes.push(subprocess)
|
||||
return subprocess
|
||||
}
|
||||
|
||||
async function temp() {
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-client-service-"))
|
||||
directories.push(directory)
|
||||
return directory
|
||||
}
|
||||
|
||||
async function waitForFile(file: string) {
|
||||
for (let attempt = 0; attempt < 600; attempt++) {
|
||||
if (await Bun.file(file).exists()) return
|
||||
await Bun.sleep(5)
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${file}`)
|
||||
}
|
||||
|
||||
async function health(url: string) {
|
||||
return fetch(new URL("/api/health", url), { signal: AbortSignal.timeout(1_000) }).then((response) => response.json())
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@
|
||||
"@ff-labs/fff-node": "0.10.5",
|
||||
"@opencode-ai/codemode": "workspace:*",
|
||||
"@opencode-ai/ai": "workspace:*",
|
||||
"@opencode-ai/pty": "0.1.12",
|
||||
"@opencode-ai/pty": "0.1.13",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
|
||||
+39
-38
@@ -17,15 +17,14 @@ import type {
|
||||
} from "@ai-sdk/provider"
|
||||
import {
|
||||
FinishReason,
|
||||
InvalidProviderOutputReason,
|
||||
LLMEvent,
|
||||
AIError,
|
||||
LanguageModel,
|
||||
ProviderID,
|
||||
ProviderMetadata,
|
||||
TransportReason,
|
||||
TransportError,
|
||||
ToolResultValue,
|
||||
UnknownProviderReason,
|
||||
UnknownProviderError,
|
||||
type ContentPart,
|
||||
type LLMRequest,
|
||||
type ToolDefinition,
|
||||
@@ -613,12 +612,12 @@ function streamLanguage(language: LanguageModelV3, options: LanguageModelV3CallO
|
||||
Stream.unwrap(
|
||||
Effect.tryPromise({
|
||||
try: () => language.doStream(options),
|
||||
catch: (error) => llmError("doStream", error),
|
||||
catch: llmError,
|
||||
}).pipe(
|
||||
Effect.map((result) =>
|
||||
Stream.fromReadableStream({
|
||||
evaluate: () => result.stream,
|
||||
onError: (error) => llmError("readStream", error),
|
||||
onError: llmError,
|
||||
}).pipe(
|
||||
Stream.mapEffect((event) => streamPartEvents(state, event)),
|
||||
Stream.flatMap((events) => Stream.fromIterable(events)),
|
||||
@@ -745,7 +744,7 @@ function streamPartEvents(
|
||||
}),
|
||||
])
|
||||
case "error":
|
||||
return Effect.fail(llmError("stream", event.error))
|
||||
return Effect.fail(llmError(event.error))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -795,45 +794,51 @@ function messageValue(input: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
function llmError(method: string, error: unknown) {
|
||||
const reason =
|
||||
error instanceof AIError
|
||||
? new InvalidProviderOutputReason({ message: error.message })
|
||||
: APICallError.isInstance(error)
|
||||
? apiCallErrorReason(error)
|
||||
: new UnknownProviderReason({ message: unknownErrorMessage(error) })
|
||||
function llmError(error: unknown) {
|
||||
if (error instanceof AIError) return error
|
||||
if (APICallError.isInstance(error)) return apiCallError(error)
|
||||
return new AIError({
|
||||
module: "AISDK",
|
||||
method,
|
||||
reason,
|
||||
reason: new UnknownProviderError({
|
||||
message: unknownErrorMessage(error),
|
||||
body: errorBody(error),
|
||||
cause: error,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
function apiCallErrorReason(error: APICallError) {
|
||||
const details = providerErrorDetails(error)
|
||||
const reason = RequestExecutor.classifyHttpFailure({
|
||||
message: details.message,
|
||||
function apiCallError(error: APICallError) {
|
||||
const failure = RequestExecutor.httpFailure({
|
||||
message: providerErrorMessage(error),
|
||||
url: error.url,
|
||||
status: error.statusCode,
|
||||
code: details.code,
|
||||
data: error.data,
|
||||
responseHeaders: error.responseHeaders,
|
||||
responseBody: error.responseBody,
|
||||
responseBody: error.responseBody ?? errorBody(error.data),
|
||||
cause: error,
|
||||
})
|
||||
if (error.statusCode !== undefined || !error.isRetryable) return reason
|
||||
return new TransportReason({
|
||||
message: reason.message,
|
||||
transport: "http",
|
||||
operation: "request",
|
||||
code: error.name,
|
||||
url: error.url,
|
||||
http: "http" in reason ? reason.http : undefined,
|
||||
if (error.statusCode !== undefined || !error.isRetryable) return failure
|
||||
return new AIError({
|
||||
reason: new TransportError({
|
||||
message: failure.message,
|
||||
body: failure.reason.body,
|
||||
http: failure.reason.http,
|
||||
cause: failure.reason.cause,
|
||||
transport: "http",
|
||||
operation: "request",
|
||||
url: error.url,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
const ProviderErrorCode = Schema.Union([Schema.String, Schema.Finite])
|
||||
function errorBody(value: unknown) {
|
||||
if (typeof value === "string") return value
|
||||
if (value instanceof Error || !Schema.is(Schema.Json)(value)) return undefined
|
||||
return ProviderShared.encodeJson(value)
|
||||
}
|
||||
|
||||
const ProviderErrorDetail = Schema.Struct({
|
||||
message: Schema.optionalKey(Schema.String),
|
||||
code: Schema.optionalKey(ProviderErrorCode),
|
||||
code: Schema.optionalKey(Schema.Union([Schema.String, Schema.Finite])),
|
||||
})
|
||||
const ProviderErrorBody = Schema.Struct({
|
||||
...ProviderErrorDetail.fields,
|
||||
@@ -848,7 +853,7 @@ function unknownErrorMessage(error: unknown) {
|
||||
return message.trim() === "" ? "Provider request failed" : message
|
||||
}
|
||||
|
||||
function providerErrorDetails(error: APICallError) {
|
||||
function providerErrorMessage(error: APICallError) {
|
||||
const data = Option.getOrUndefined(decodeProviderError(error.data))
|
||||
const body = Option.getOrUndefined(decodeProviderError(error.responseBody))
|
||||
const details = [data?.error, data, body?.error, body]
|
||||
@@ -857,11 +862,7 @@ function providerErrorDetails(error: APICallError) {
|
||||
const code = value === undefined ? undefined : String(value)
|
||||
const prefix =
|
||||
error.statusCode === undefined ? "Provider request failed" : `Provider request failed with HTTP ${error.statusCode}`
|
||||
return {
|
||||
code,
|
||||
message:
|
||||
error.message.trim() !== "" ? error.message : (message ?? (code === undefined ? prefix : `${prefix}: ${code}`)),
|
||||
}
|
||||
return error.message.trim() !== "" ? error.message : (message ?? (code === undefined ? prefix : `${prefix}: ${code}`))
|
||||
}
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [] })
|
||||
|
||||
@@ -39,7 +39,9 @@ export function parse(directory: string, filepath: string, content: string): Par
|
||||
if (Result.isFailure(decoded)) return { _tag: "Skipped", reason: "frontmatter", issue: decoded.failure }
|
||||
const frontmatter = decoded.success
|
||||
const id =
|
||||
path.dirname(filepath) === directory ? path.basename(filepath, ".md") : path.basename(path.dirname(filepath))
|
||||
path.dirname(filepath) === directory && path.basename(filepath) !== "SKILL.md"
|
||||
? path.basename(filepath, ".md")
|
||||
: path.basename(path.dirname(filepath))
|
||||
const slash = metadataBoolean(frontmatter.metadata, "opencode/slash") ?? frontmatter.slash
|
||||
const autoinvoke = metadataBoolean(frontmatter.metadata, "opencode/autoinvoke")
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NodeSocket } from "@effect/platform-node"
|
||||
// The platform barrel also exposes Redis and its optional native hash loader, which workerd cannot resolve.
|
||||
import { NodeWS } from "@effect/platform-node/NodeSocket"
|
||||
import { HttpProxyAgent } from "http-proxy-agent"
|
||||
import { HttpsProxyAgent } from "https-proxy-agent"
|
||||
import { Layer } from "effect"
|
||||
@@ -80,8 +81,8 @@ const layer = Layer.succeed(Socket.WebSocketConstructor, (url, input) => {
|
||||
followRedirects: false,
|
||||
}
|
||||
const socket = config.protocols
|
||||
? new NodeSocket.NodeWS.WebSocket(url, config.protocols, native)
|
||||
: new NodeSocket.NodeWS.WebSocket(url, native)
|
||||
? new NodeWS.WebSocket(url, config.protocols, native)
|
||||
: new NodeWS.WebSocket(url, native)
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- ws implements the WebSocket surface consumed by the AI transport.
|
||||
return socket as unknown as globalThis.WebSocket
|
||||
})
|
||||
|
||||
@@ -35,23 +35,14 @@ export function make(input: {
|
||||
)
|
||||
.pipe(Effect.option)
|
||||
|
||||
const gofmt: Info = {
|
||||
name: "gofmt",
|
||||
extensions: [".go"],
|
||||
enabled: Effect.sync(() => {
|
||||
const match = findExecutable("gofmt")
|
||||
return match ? [match, "-w", "$FILE"] : disabled
|
||||
}),
|
||||
}
|
||||
const gofmt = executable("gofmt", [".go"], ["-w", "$FILE"], findExecutable)
|
||||
|
||||
const mix: Info = {
|
||||
name: "mix",
|
||||
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
|
||||
enabled: Effect.sync(() => {
|
||||
const match = findExecutable("mix")
|
||||
return match ? [match, "format", "$FILE"] : disabled
|
||||
}),
|
||||
}
|
||||
const mix = executable(
|
||||
"mix",
|
||||
[".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
|
||||
["format", "$FILE"],
|
||||
findExecutable,
|
||||
)
|
||||
|
||||
const prettier: Info = {
|
||||
name: "prettier",
|
||||
@@ -147,14 +138,7 @@ export function make(input: {
|
||||
}).pipe(Effect.orElseSucceed(() => disabled)),
|
||||
}
|
||||
|
||||
const zig: Info = {
|
||||
name: "zig",
|
||||
extensions: [".zig", ".zon"],
|
||||
enabled: Effect.sync(() => {
|
||||
const match = findExecutable("zig")
|
||||
return match ? [match, "fmt", "$FILE"] : disabled
|
||||
}),
|
||||
}
|
||||
const zig = executable("zig", [".zig", ".zon"], ["fmt", "$FILE"], findExecutable)
|
||||
|
||||
const clang: Info = {
|
||||
name: "clang-format",
|
||||
@@ -166,14 +150,7 @@ export function make(input: {
|
||||
}).pipe(Effect.orElseSucceed(() => disabled)),
|
||||
}
|
||||
|
||||
const ktlint: Info = {
|
||||
name: "ktlint",
|
||||
extensions: [".kt", ".kts"],
|
||||
enabled: Effect.sync(() => {
|
||||
const match = findExecutable("ktlint")
|
||||
return match ? [match, "-F", "$FILE"] : disabled
|
||||
}),
|
||||
}
|
||||
const ktlint = executable("ktlint", [".kt", ".kts"], ["-F", "$FILE"], findExecutable)
|
||||
|
||||
const ruff: Info = {
|
||||
name: "ruff",
|
||||
|
||||
@@ -398,22 +398,16 @@ const layer = Layer.effect(
|
||||
],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const candidates = Array.from(new Set([...tracked, ...untracked]))
|
||||
const candidates = Array.from(new Set([...tracked, ...untracked])).map((file) => RelativePath.make(file))
|
||||
if (!candidates.length) return { skipped: [] }
|
||||
const ignored = input.ignores
|
||||
? new Set(
|
||||
(yield* repositoryOperation("refresh", input.ignores, ["check-ignore", "--no-index", "--stdin", "-z"], {
|
||||
stdin: candidates.join("\0") + "\0",
|
||||
}).pipe(Effect.orElseSucceed(() => ({ text: "", stderr: "" })))).text
|
||||
.split("\0")
|
||||
.filter(Boolean),
|
||||
)
|
||||
: new Set<string>()
|
||||
const allowed = candidates.filter((item) => !ignored.has(item))
|
||||
const excluded = input.ignores
|
||||
? yield* ignored({ repository: input.ignores, paths: candidates })
|
||||
: new Set<RelativePath>()
|
||||
const allowed = candidates.filter((item) => !excluded.has(item))
|
||||
const maximum = input.maximumUntrackedFileBytes
|
||||
const skipped = maximum
|
||||
? (yield* Effect.forEach(
|
||||
untracked.filter((item) => allowed.includes(item)),
|
||||
untracked.filter((item) => allowed.includes(RelativePath.make(item))),
|
||||
(item) =>
|
||||
fs.stat(path.join(input.repository.worktree, item)).pipe(
|
||||
Effect.map((info) =>
|
||||
@@ -424,8 +418,8 @@ const layer = Layer.effect(
|
||||
{ concurrency: 8 },
|
||||
)).filter((item): item is RelativePath => item !== undefined)
|
||||
: []
|
||||
const stage = allowed.filter((item) => !skipped.includes(RelativePath.make(item)))
|
||||
const remove = [...ignored, ...skipped]
|
||||
const stage = allowed.filter((item) => !skipped.includes(item))
|
||||
const remove = [...excluded, ...skipped]
|
||||
if (remove.length)
|
||||
yield* repositoryOperation(
|
||||
"refresh",
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Agent } from "./agent.js"
|
||||
import { AISDK } from "./aisdk.js"
|
||||
import { Catalog } from "./catalog.js"
|
||||
import { Command } from "./command.js"
|
||||
import { Config } from "./config.js"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Node } from "@opencode-ai/util/effect/app-node"
|
||||
import { FileMutation } from "./file-mutation.js"
|
||||
import { Environment } from "./environment/index.js"
|
||||
import { Formatter } from "./formatter.js"
|
||||
import { FileSystem } from "./filesystem.js"
|
||||
import { FileSystemSearch } from "./filesystem/search.js"
|
||||
import { Generate } from "./generate.js"
|
||||
import { Form } from "./form.js"
|
||||
import { Image } from "./image.js"
|
||||
import { LocationWatcher } from "./filesystem/location-watcher.js"
|
||||
import { Integration } from "./integration.js"
|
||||
import { Location } from "./location.js"
|
||||
import { LocationMutation } from "./location-mutation.js"
|
||||
import { ModelResolver } from "./model-resolver.js"
|
||||
import { MCP } from "./mcp/index.js"
|
||||
import { Permission } from "./permission.js"
|
||||
import { Plugin } from "./plugin.js"
|
||||
import { PluginHooks } from "./plugin/hooks.js"
|
||||
import { InstancePlugins } from "./plugin/instance.js"
|
||||
import { PluginSupervisor } from "./plugin/supervisor.js"
|
||||
import { Worktree } from "./worktree.js"
|
||||
import { Pty } from "./pty.js"
|
||||
import { Shell } from "./shell.js"
|
||||
import { ShellSelect } from "./shell/select.js"
|
||||
import { Reference } from "./reference.js"
|
||||
import { WebSearch } from "./websearch.js"
|
||||
import { ReferenceInstructions } from "./reference/instructions.js"
|
||||
import { SessionRunnerLLM } from "./session/runner/llm.js"
|
||||
import { SessionRunnerModel } from "./session/runner/model.js"
|
||||
import { SessionModelTransport } from "./session/model-transport.js"
|
||||
import { SessionCompaction } from "./session/compaction.js"
|
||||
import { SessionTitle } from "./session/title.js"
|
||||
import { Skill } from "./skill.js"
|
||||
import { SkillInstructions } from "./skill/instructions.js"
|
||||
import { Snapshot } from "./snapshot.js"
|
||||
import { InstructionDiscovery } from "./instruction-discovery.js"
|
||||
import { InstructionBuiltIns } from "./instructions/builtins.js"
|
||||
import { InstructionEntry } from "./session/instruction-entry.js"
|
||||
import { SessionInstructions } from "./session/instructions.js"
|
||||
import { SessionGenerateNode } from "./session/generate-node.js"
|
||||
import { McpTool } from "./tool/mcp.js"
|
||||
import { ReadToolFileSystem } from "./tool/read-filesystem.js"
|
||||
import { Tool } from "./tool.js"
|
||||
import { ToolOutput } from "./tool-output.js"
|
||||
import { Vcs } from "./vcs.js"
|
||||
|
||||
export * as Instance from "./instance.js"
|
||||
|
||||
const nodes = [
|
||||
Location.node,
|
||||
Environment.node,
|
||||
Config.node,
|
||||
Agent.node,
|
||||
Command.node,
|
||||
Reference.node,
|
||||
WebSearch.node,
|
||||
Integration.node,
|
||||
Catalog.node,
|
||||
ModelResolver.node,
|
||||
AISDK.node,
|
||||
Plugin.node,
|
||||
PluginHooks.node,
|
||||
InstancePlugins.node,
|
||||
PluginSupervisor.node,
|
||||
Worktree.refreshNode,
|
||||
FileSystemSearch.node,
|
||||
FileSystem.node,
|
||||
ShellSelect.node,
|
||||
Pty.node,
|
||||
Shell.node,
|
||||
Skill.node,
|
||||
InstructionBuiltIns.node,
|
||||
InstructionDiscovery.node,
|
||||
LocationMutation.node,
|
||||
FileMutation.node,
|
||||
Formatter.node,
|
||||
MCP.node,
|
||||
Permission.node,
|
||||
Tool.node,
|
||||
ToolOutput.node,
|
||||
Image.node,
|
||||
SkillInstructions.node,
|
||||
ReferenceInstructions.node,
|
||||
InstructionEntry.node,
|
||||
Form.node,
|
||||
Generate.node,
|
||||
SessionGenerateNode.node,
|
||||
ReadToolFileSystem.node,
|
||||
McpTool.node,
|
||||
SessionInstructions.node,
|
||||
SessionRunnerModel.node,
|
||||
SessionModelTransport.node,
|
||||
SessionCompaction.node,
|
||||
SessionTitle.node,
|
||||
Snapshot.node,
|
||||
SessionRunnerLLM.node,
|
||||
Vcs.node,
|
||||
// Start repository watches only after boot-critical filesystem and Git work.
|
||||
LocationWatcher.node,
|
||||
] as const satisfies readonly Node.LocationNode<unknown, unknown>[]
|
||||
|
||||
export const graph = LayerNode.group<typeof nodes>(nodes)
|
||||
|
||||
export type Services = LayerNode.Output<typeof graph>
|
||||
export type Error = LayerNode.Error<typeof graph>
|
||||
|
||||
export interface Options {
|
||||
// Plugins this instance is born with; empty and absent are equivalent.
|
||||
readonly plugins?: InstancePlugins.List
|
||||
readonly replacements?: LayerNode.Replacements
|
||||
}
|
||||
|
||||
// One instance is one compiled, fresh copy of the graph standing on a directory.
|
||||
export function layer(ref: Location.Ref, options: Options = {}) {
|
||||
const startedAt = performance.now()
|
||||
// Bound pairs come last, so they win over caller replacements of the same nodes.
|
||||
const allReplacements = (options.replacements ?? []).concat([
|
||||
[Location.node, Location.boundNode(ref)],
|
||||
[InstancePlugins.node, InstancePlugins.bound(options.plugins ?? [])],
|
||||
])
|
||||
// Apply replacements during hoist, not afterward: replacements can
|
||||
// introduce new tagged dependencies (Location.boundNode depends on
|
||||
// Project), and the hoist walk is the only pass that can still slice
|
||||
// those back out.
|
||||
const location = LayerNode.hoist(graph, Node.tags.values.global, allReplacements)
|
||||
|
||||
return LayerNode.compile(location.node).pipe(
|
||||
Layer.fresh,
|
||||
Layer.tap(() =>
|
||||
Effect.logInfo("location services booted", {
|
||||
directory: ref.directory,
|
||||
workspaceID: ref.workspaceID,
|
||||
durationMs: Math.round(performance.now() - startedAt),
|
||||
}),
|
||||
),
|
||||
Layer.provide(LayerNode.compile(location.hoisted)),
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Location } from "./location.js"
|
||||
import { Project } from "./project.js"
|
||||
import { ProjectMarkers } from "./project/markers.js"
|
||||
@@ -13,9 +14,9 @@ export const Kind = Schema.Literals(["file", "directory"])
|
||||
export type Kind = typeof Kind.Type
|
||||
|
||||
/**
|
||||
* Mutation paths do not accept project references. Relative paths resolve
|
||||
* from the active Location. Paths outside it require separate
|
||||
* `external_directory` approval.
|
||||
* Mutation paths do not accept project references. A leading `~` expands to
|
||||
* the home directory; other relative paths resolve from the active Location.
|
||||
* Paths outside it require separate `external_directory` approval.
|
||||
*/
|
||||
export const ResolveInput = Schema.Struct({
|
||||
path: Schema.String,
|
||||
@@ -49,13 +50,25 @@ export interface Target {
|
||||
|
||||
export interface Interface {
|
||||
/**
|
||||
* Resolve a path and derive its permission resources. Relative paths resolve
|
||||
* from the Location. Paths outside it require separate `external_directory`
|
||||
* approval. This does not approve the mutation.
|
||||
* Resolve a path and derive its permission resources. A leading `~` expands
|
||||
* to the home directory; other relative paths resolve from the Location.
|
||||
* Paths outside it require separate `external_directory` approval. This does
|
||||
* not approve the mutation.
|
||||
*/
|
||||
readonly resolve: (input: ResolveInput) => Effect.Effect<Target, FSUtil.Error>
|
||||
}
|
||||
|
||||
/** Lexical absolute path, expanding a leading `~` before resolving against `directory`. */
|
||||
export const resolvePath = (directory: string, input: string, home = Global.Path.home) =>
|
||||
path.resolve(
|
||||
directory,
|
||||
input === "~"
|
||||
? home
|
||||
: input.startsWith("~/") || (process.platform === "win32" && input.startsWith("~\\"))
|
||||
? path.join(home, input.slice(2))
|
||||
: input,
|
||||
)
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LocationMutation") {}
|
||||
|
||||
const slash = (value: string) => value.replaceAll("\\", "/")
|
||||
@@ -68,7 +81,7 @@ const layer = Layer.effect(
|
||||
const markers = yield* ProjectMarkers.Service
|
||||
|
||||
const resolve = Effect.fnUntraced(function* (input: ResolveInput) {
|
||||
const absolute = path.resolve(location.directory, input.path)
|
||||
const absolute = resolvePath(location.directory, input.path)
|
||||
if (FSUtil.contains(location.directory, absolute)) {
|
||||
return {
|
||||
absolute,
|
||||
|
||||
@@ -2,11 +2,11 @@ import { Context, Effect, Layer, LayerMap } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Node } from "@opencode-ai/util/effect/app-node"
|
||||
import { Location } from "./location.js"
|
||||
import type { LocationError, LocationServices } from "./location-services.js"
|
||||
import type { Instance } from "./instance.js"
|
||||
|
||||
export class Service extends Context.Service<
|
||||
Service,
|
||||
LayerMap.LayerMap<Location.Ref, LocationServices, LocationError>
|
||||
LayerMap.LayerMap<Location.Ref, Instance.Services, Instance.Error>
|
||||
>()("@opencode/example/LocationServiceMap") {
|
||||
static get(ref: Location.Ref) {
|
||||
return Layer.unwrap(Effect.map(Service, (locations) => locations.get(ref)))
|
||||
|
||||
@@ -1,118 +1,16 @@
|
||||
import { Duration, Effect, Layer, LayerMap } from "effect"
|
||||
import { existsSync } from "fs"
|
||||
import path from "path"
|
||||
import { Agent } from "./agent.js"
|
||||
import { AISDK } from "./aisdk.js"
|
||||
import { Catalog } from "./catalog.js"
|
||||
import { Command } from "./command.js"
|
||||
import { Config } from "./config.js"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Node } from "@opencode-ai/util/effect/app-node"
|
||||
import { Bus } from "./bus.js"
|
||||
import { FileMutation } from "./file-mutation.js"
|
||||
import { Environment } from "./environment/index.js"
|
||||
import { Formatter } from "./formatter.js"
|
||||
import { FileSystem } from "./filesystem.js"
|
||||
import { FileSystemSearch } from "./filesystem/search.js"
|
||||
import { Generate } from "./generate.js"
|
||||
import { Form } from "./form.js"
|
||||
import { Image } from "./image.js"
|
||||
import { LocationWatcher } from "./filesystem/location-watcher.js"
|
||||
import { Integration } from "./integration.js"
|
||||
import { Instance } from "./instance.js"
|
||||
import { Location } from "./location.js"
|
||||
import { LocationMutation } from "./location-mutation.js"
|
||||
import { LocationServiceMap } from "./location-service-map.js"
|
||||
import { ModelResolver } from "./model-resolver.js"
|
||||
import { MCP } from "./mcp/index.js"
|
||||
import { Permission } from "./permission.js"
|
||||
import { Plugin } from "./plugin.js"
|
||||
import { PluginHooks } from "./plugin/hooks.js"
|
||||
import { PluginSupervisor } from "./plugin/supervisor.js"
|
||||
import { Worktree } from "./worktree.js"
|
||||
import { Pty } from "./pty.js"
|
||||
import { Shell } from "./shell.js"
|
||||
import { ShellSelect } from "./shell/select.js"
|
||||
import { Reference } from "./reference.js"
|
||||
import { WebSearch } from "./websearch.js"
|
||||
import { ReferenceInstructions } from "./reference/instructions.js"
|
||||
import { SessionRunnerLLM } from "./session/runner/llm.js"
|
||||
import { SessionRunnerModel } from "./session/runner/model.js"
|
||||
import { SessionModelTransport } from "./session/model-transport.js"
|
||||
import { SessionCompaction } from "./session/compaction.js"
|
||||
import { SessionTitle } from "./session/title.js"
|
||||
import { Skill } from "./skill.js"
|
||||
import { SkillInstructions } from "./skill/instructions.js"
|
||||
import { Snapshot } from "./snapshot.js"
|
||||
import { InstructionDiscovery } from "./instruction-discovery.js"
|
||||
import { InstructionBuiltIns } from "./instructions/builtins.js"
|
||||
import { InstructionEntry } from "./session/instruction-entry.js"
|
||||
import { SessionInstructions } from "./session/instructions.js"
|
||||
import { SessionGenerateNode } from "./session/generate-node.js"
|
||||
import { McpTool } from "./tool/mcp.js"
|
||||
import { ReadToolFileSystem } from "./tool/read-filesystem.js"
|
||||
import { Tool } from "./tool.js"
|
||||
import { ToolOutput } from "./tool-output.js"
|
||||
import { Vcs } from "./vcs.js"
|
||||
import { AbsolutePath } from "./schema.js"
|
||||
|
||||
export { LocationServiceMap } from "./location-service-map.js"
|
||||
|
||||
const locationServiceNodes = [
|
||||
Location.node,
|
||||
Environment.node,
|
||||
Config.node,
|
||||
Agent.node,
|
||||
Command.node,
|
||||
Reference.node,
|
||||
WebSearch.node,
|
||||
Integration.node,
|
||||
Catalog.node,
|
||||
ModelResolver.node,
|
||||
AISDK.node,
|
||||
Plugin.node,
|
||||
PluginHooks.node,
|
||||
PluginSupervisor.node,
|
||||
Worktree.refreshNode,
|
||||
FileSystemSearch.node,
|
||||
FileSystem.node,
|
||||
ShellSelect.node,
|
||||
Pty.node,
|
||||
Shell.node,
|
||||
Skill.node,
|
||||
InstructionBuiltIns.node,
|
||||
InstructionDiscovery.node,
|
||||
LocationMutation.node,
|
||||
FileMutation.node,
|
||||
Formatter.node,
|
||||
MCP.node,
|
||||
Permission.node,
|
||||
Tool.node,
|
||||
ToolOutput.node,
|
||||
Image.node,
|
||||
SkillInstructions.node,
|
||||
ReferenceInstructions.node,
|
||||
InstructionEntry.node,
|
||||
Form.node,
|
||||
Generate.node,
|
||||
SessionGenerateNode.node,
|
||||
ReadToolFileSystem.node,
|
||||
McpTool.node,
|
||||
SessionInstructions.node,
|
||||
SessionRunnerModel.node,
|
||||
SessionModelTransport.node,
|
||||
SessionCompaction.node,
|
||||
SessionTitle.node,
|
||||
Snapshot.node,
|
||||
SessionRunnerLLM.node,
|
||||
Vcs.node,
|
||||
// Start repository watches only after boot-critical filesystem and Git work.
|
||||
LocationWatcher.node,
|
||||
] as const satisfies readonly Node.LocationNode<unknown, unknown>[]
|
||||
|
||||
export const locationServices = LayerNode.group<typeof locationServiceNodes>(locationServiceNodes)
|
||||
|
||||
export type LocationServices = LayerNode.Output<typeof locationServices>
|
||||
export type LocationError = LayerNode.Error<typeof locationServices>
|
||||
export type LocationServices = Instance.Services
|
||||
export type LocationError = Instance.Error
|
||||
|
||||
export function buildLocationServiceMap(
|
||||
replacements: LayerNode.Replacements = [],
|
||||
@@ -127,37 +25,14 @@ export function buildLocationServiceMap(
|
||||
return Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
Effect.map(
|
||||
LayerMap.make(
|
||||
(ref: Location.Ref) => {
|
||||
const startedAt = performance.now()
|
||||
const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]])
|
||||
// Apply replacements during hoist, not afterward: replacements can
|
||||
// introduce new tagged dependencies (Location.boundNode depends on
|
||||
// Project), and the hoist walk is the only pass that can still slice
|
||||
// those back out.
|
||||
const location = LayerNode.hoist(locationServices, Node.tags.values.global, allReplacements)
|
||||
|
||||
return LayerNode.compile(location.node).pipe(
|
||||
Layer.fresh,
|
||||
Layer.tap(() =>
|
||||
Effect.logInfo("location services booted", {
|
||||
directory: ref.directory,
|
||||
workspaceID: ref.workspaceID,
|
||||
durationMs: Math.round(performance.now() - startedAt),
|
||||
}),
|
||||
),
|
||||
Layer.provide(LayerNode.compile(location.hoisted)),
|
||||
)
|
||||
},
|
||||
{
|
||||
// Workspace-placed directories exist only inside the workspace, so a
|
||||
// local stat consults the wrong filesystem. Workspace liveness is
|
||||
// owned by placement; do not probe the sandbox here, which would
|
||||
// provision lazily-idle workspaces.
|
||||
idleTimeToLive: (ref) =>
|
||||
ref.workspaceID !== undefined || existsSync(ref.directory) ? Duration.infinity : Duration.zero,
|
||||
},
|
||||
),
|
||||
LayerMap.make((ref: Location.Ref) => Instance.layer(ref, { replacements }), {
|
||||
// Workspace-placed directories exist only inside the workspace, so a
|
||||
// local stat consults the wrong filesystem. Workspace liveness is
|
||||
// owned by placement; do not probe the sandbox here, which would
|
||||
// provision lazily-idle workspaces.
|
||||
idleTimeToLive: (ref) =>
|
||||
ref.workspaceID !== undefined || existsSync(ref.directory) ? Duration.infinity : Duration.zero,
|
||||
}),
|
||||
(inner) => ({
|
||||
...inner,
|
||||
get: (ref: Location.Ref) => inner.get(canonical(ref)),
|
||||
|
||||
@@ -3,7 +3,7 @@ export * as MCPClient from "./client.js"
|
||||
import path from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
||||
import { StreamableHTTPClientTransport, StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
||||
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"
|
||||
import { UnauthorizedError, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import {
|
||||
@@ -191,10 +191,38 @@ export const connect = Effect.fnUntraced(function* (
|
||||
elicitation?: ElicitationHandler,
|
||||
clientInfo: Implementation = { name: "opencode", version: "unknown" },
|
||||
) {
|
||||
const transport: Transport = yield* Effect.gen(function* () {
|
||||
const initialize = Effect.fnUntraced(function* (transport: Transport) {
|
||||
const client = new Client(clientInfo, {
|
||||
capabilities: {
|
||||
...(elicitation ? { elicitation: { form: { applyDefaults: true }, url: {} } } : {}),
|
||||
// https://github.com/anomalyco/opencode/issues/2308
|
||||
roots: {},
|
||||
},
|
||||
})
|
||||
client.setRequestHandler(ListRootsRequestSchema, () =>
|
||||
Promise.resolve({ roots: [{ uri: pathToFileURL(directory).href }] }),
|
||||
)
|
||||
if (elicitation) {
|
||||
client.setRequestHandler(ElicitRequestSchema, (request, extra) =>
|
||||
Effect.runPromise(elicitation.create({ server, params: request.params, signal: extra.signal })),
|
||||
)
|
||||
client.setNotificationHandler(ElicitationCompleteNotificationSchema, (notification) =>
|
||||
Effect.runPromise(elicitation.complete({ server, elicitationID: notification.params.elicitationId })),
|
||||
)
|
||||
}
|
||||
|
||||
yield* Effect.tryPromise({
|
||||
try: (signal) =>
|
||||
client.connect(transport, { timeout: config.timeout?.startup ?? DEFAULT_STARTUP_TIMEOUT, signal }),
|
||||
catch: (error) => error,
|
||||
}).pipe(Effect.onError(() => Effect.promise(() => transport.close()).pipe(Effect.ignore)))
|
||||
return client
|
||||
})
|
||||
|
||||
const exit = yield* Effect.gen(function* () {
|
||||
if (config.type === "local") {
|
||||
const [command, ...args] = config.command
|
||||
return yield* MCPStdio.make({
|
||||
const transport = yield* MCPStdio.make({
|
||||
server,
|
||||
command,
|
||||
args,
|
||||
@@ -204,41 +232,32 @@ export const connect = Effect.fnUntraced(function* (
|
||||
...config.environment,
|
||||
},
|
||||
})
|
||||
return yield* initialize(transport)
|
||||
}
|
||||
if (!URL.canParse(config.url))
|
||||
return yield* new ConnectError({ server, message: `Invalid MCP URL for "${server}"` })
|
||||
// Prefer raw tools for our Code Mode without changing the configured URL used for OAuth identity.
|
||||
const url = new URL(config.url)
|
||||
if (config.codemode !== false && !url.searchParams.has("codemode")) url.searchParams.set("codemode", "false")
|
||||
return new StreamableHTTPClientTransport(url, {
|
||||
requestInit: config.headers ? { headers: config.headers } : undefined,
|
||||
authProvider,
|
||||
})
|
||||
})
|
||||
const client = new Client(clientInfo, {
|
||||
capabilities: {
|
||||
...(elicitation ? { elicitation: { form: { applyDefaults: true }, url: {} } } : {}),
|
||||
// https://github.com/anomalyco/opencode/issues/2308
|
||||
roots: {},
|
||||
},
|
||||
})
|
||||
client.setRequestHandler(ListRootsRequestSchema, () =>
|
||||
Promise.resolve({ roots: [{ uri: pathToFileURL(directory).href }] }),
|
||||
)
|
||||
if (elicitation) {
|
||||
client.setRequestHandler(ElicitRequestSchema, (request, extra) =>
|
||||
Effect.runPromise(elicitation.create({ server, params: request.params, signal: extra.signal })),
|
||||
)
|
||||
client.setNotificationHandler(ElicitationCompleteNotificationSchema, (notification) =>
|
||||
Effect.runPromise(elicitation.complete({ server, elicitationID: notification.params.elicitationId })),
|
||||
)
|
||||
}
|
||||
const addedCodemode = config.codemode !== false && !url.searchParams.has("codemode")
|
||||
if (addedCodemode) url.searchParams.set("codemode", "false")
|
||||
const open = (url: URL) =>
|
||||
initialize(
|
||||
new StreamableHTTPClientTransport(url, {
|
||||
requestInit: config.headers ? { headers: config.headers } : undefined,
|
||||
authProvider,
|
||||
}),
|
||||
)
|
||||
|
||||
const exit = yield* Effect.tryPromise({
|
||||
try: (signal) => client.connect(transport, { timeout: config.timeout?.startup ?? DEFAULT_STARTUP_TIMEOUT, signal }),
|
||||
catch: (error) => error,
|
||||
return yield* open(url).pipe(
|
||||
Effect.catch((error) => {
|
||||
if (!addedCodemode || !(error instanceof StreamableHTTPError) || error.code !== 404) return Effect.fail(error)
|
||||
// Some servers reject unknown query params. Retry once with the user's original URL.
|
||||
return open(new URL(config.url))
|
||||
}),
|
||||
)
|
||||
}).pipe(Effect.exit)
|
||||
if (Exit.isSuccess(exit)) {
|
||||
const client = exit.value
|
||||
// Closing the client closes the transport, which ends stdin and then kills through the spawner
|
||||
// handle if the server does not exit cleanly. The process scope remains a final backstop.
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => client.close()).pipe(Effect.ignore))
|
||||
@@ -440,7 +459,6 @@ export const connect = Effect.fnUntraced(function* (
|
||||
} satisfies Connection
|
||||
}
|
||||
|
||||
yield* Effect.promise(() => transport.close()).pipe(Effect.ignore)
|
||||
const error = Cause.squash(exit.cause)
|
||||
if (error instanceof UnauthorizedError) return yield* new NeedsAuthError({ server })
|
||||
return yield* new ConnectError({ server, message: error instanceof Error ? error.message : String(error) })
|
||||
|
||||
@@ -261,7 +261,7 @@ export const resolveModel = (
|
||||
dependencies?: Dependencies,
|
||||
) => withVariant(model, variant).pipe(Effect.flatMap((model) => fromCatalogModel(model, credential, dependencies)))
|
||||
|
||||
export const supported = (model: Info) => Boolean(model.package)
|
||||
export const hasPackage = (model: Info) => Boolean(model.package)
|
||||
|
||||
/** Resolves catalog selections into runtime models for the current Location. */
|
||||
export const layer = Layer.effect(
|
||||
@@ -309,9 +309,9 @@ export const layer = Layer.effect(
|
||||
.default()
|
||||
.pipe(
|
||||
Effect.flatMap((model) =>
|
||||
model && supported(model)
|
||||
model && hasPackage(model)
|
||||
? Effect.succeed(model)
|
||||
: Effect.map(catalog.model.available(), (models) => models.find(supported)),
|
||||
: Effect.map(catalog.model.available(), (models) => models.find(hasPackage)),
|
||||
),
|
||||
)
|
||||
if (!selected) return undefined
|
||||
|
||||
@@ -170,7 +170,7 @@ const layer = Layer.effect(
|
||||
if (denied(input, rules)) return { effect: "deny" as const, rules }
|
||||
const all = [...rules, ...(yield* savedRules())]
|
||||
const effects = input.resources.map((resource) => evaluate(input.action, resource, all).effect)
|
||||
const effect: Permission.Effect = effects.includes("deny") ? "deny" : effects.includes("ask") ? "ask" : "allow"
|
||||
const effect: Permission.Effect = effects.includes("ask") ? "ask" : "allow"
|
||||
const event = yield* hooks.trigger("permission", "evaluate", {
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
|
||||
@@ -3,8 +3,9 @@ import { readFile } from "node:fs/promises"
|
||||
import net from "node:net"
|
||||
import path from "node:path"
|
||||
import { Data, Duration, Effect, Schema, Semaphore } from "effect"
|
||||
import type { Handoff } from "@opencode-ai/schema/persistent-pty"
|
||||
|
||||
const ProtocolVersion = 6
|
||||
const ProtocolVersion = 7
|
||||
const MaxFrameBytes = 8 * 1024 * 1024
|
||||
|
||||
const Lifecycle = Schema.Union([
|
||||
@@ -48,6 +49,8 @@ export const WireResponse = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal("created"), terminal: WireTerminal }),
|
||||
Schema.Struct({ type: Schema.Literal("terminals"), terminals: Schema.Array(WireTerminal) }),
|
||||
Schema.Struct({ type: Schema.Literal("ok") }),
|
||||
Schema.Struct({ type: Schema.Literal("owned") }),
|
||||
Schema.Struct({ type: Schema.Literal("handoff"), ticket: Schema.String, expires_at: Schema.Number }),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("snapshot"),
|
||||
terminal: WireTerminal,
|
||||
@@ -131,6 +134,7 @@ export interface DaemonTransport {
|
||||
readonly request: (value: object, start?: boolean) => Effect.Effect<WireResponse, DaemonError>
|
||||
readonly requestIfRunning: (value: object) => Effect.Effect<WireResponse | undefined, DaemonError>
|
||||
readonly shutdown: Effect.Effect<WireResponse | undefined, DaemonError>
|
||||
readonly handoff: Effect.Effect<Handoff | null, DaemonError>
|
||||
readonly subscribe: (
|
||||
id: number,
|
||||
input: {
|
||||
@@ -147,19 +151,51 @@ export interface DaemonTransport {
|
||||
export const makeDaemonTransport = Effect.fn("PersistentPty.makeDaemonTransport")(function* (
|
||||
directory: string,
|
||||
binary: () => Promise<string> = () => Promise.resolve(process.env.OPENCODE_PTY_BIN || "opencode-pty"),
|
||||
inherited?: Handoff,
|
||||
) {
|
||||
const startup = Semaphore.makeUnsafe(1)
|
||||
let registration: Registration | undefined
|
||||
let owner: Awaited<ReturnType<typeof openOwner>> | undefined
|
||||
let closed = false
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
closed = true
|
||||
owner?.socket.destroy()
|
||||
}),
|
||||
)
|
||||
|
||||
const claim = (current: Registration, ticket?: string) =>
|
||||
Effect.tryPromise({
|
||||
try: async (signal) => {
|
||||
const connection = await openOwner(current, ticket, signal)
|
||||
if (closed || signal.aborted) {
|
||||
connection.socket.destroy()
|
||||
throw new Error("PTY owner scope is closed")
|
||||
}
|
||||
owner = connection
|
||||
registration = current
|
||||
connection.socket.once("close", () => {
|
||||
if (owner !== connection) return
|
||||
owner = undefined
|
||||
registration = undefined
|
||||
})
|
||||
},
|
||||
catch: (cause) => failure("connect", cause),
|
||||
}).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: Duration.seconds(5),
|
||||
orElse: () => Effect.fail(new DaemonError({ kind: "connect", message: "PTY ownership claim timed out" })),
|
||||
}),
|
||||
)
|
||||
|
||||
const discover = Effect.fn("PersistentPty.daemon.discover")(function* () {
|
||||
const value = yield* Effect.tryPromise({
|
||||
try: () => readFile(path.join(directory, "service.json"), "utf8"),
|
||||
catch: (cause) => failure("connect", cause),
|
||||
})
|
||||
const decoded = yield* Effect.try({
|
||||
try: () => Schema.decodeUnknownSync(Registration)(JSON.parse(value)),
|
||||
catch: (cause) => failure("protocol", cause),
|
||||
})
|
||||
const decoded = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Registration))(value).pipe(
|
||||
Effect.mapError((cause) => failure("protocol", cause)),
|
||||
)
|
||||
if (decoded.protocol !== ProtocolVersion)
|
||||
return yield* Effect.fail(
|
||||
new DaemonError({
|
||||
@@ -181,9 +217,9 @@ export const makeDaemonTransport = Effect.fn("PersistentPty.makeDaemonTransport"
|
||||
|
||||
const start = Effect.fn("PersistentPty.daemon.start")(function* () {
|
||||
const executable = yield* Effect.tryPromise({ try: binary, catch: (cause) => failure("spawn", cause) })
|
||||
yield* Effect.tryPromise({
|
||||
const child = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
new Promise<ReturnType<typeof spawn>>((resolve, reject) => {
|
||||
const child = spawn(executable, ["daemon"], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
@@ -191,43 +227,54 @@ export const makeDaemonTransport = Effect.fn("PersistentPty.makeDaemonTransport"
|
||||
})
|
||||
child.once("spawn", () => {
|
||||
child.unref()
|
||||
resolve()
|
||||
resolve(child)
|
||||
})
|
||||
child.once("error", reject)
|
||||
}),
|
||||
catch: (cause) => failure("spawn", cause),
|
||||
})
|
||||
const deadline = Date.now() + 5_000
|
||||
let last: DaemonError | undefined
|
||||
while (Date.now() < deadline) {
|
||||
const found = yield* discover().pipe(
|
||||
Effect.map((value) => ({ value })),
|
||||
Effect.catch((error) => {
|
||||
last = error
|
||||
return Effect.succeed(undefined)
|
||||
}),
|
||||
return yield* Effect.gen(function* () {
|
||||
const deadline = Date.now() + 5_000
|
||||
let last: DaemonError | undefined
|
||||
while (Date.now() < deadline) {
|
||||
const found = yield* discover().pipe(
|
||||
Effect.map((value) => ({ value })),
|
||||
Effect.catch((error) => {
|
||||
last = error
|
||||
return Effect.succeed(undefined)
|
||||
}),
|
||||
)
|
||||
if (found) {
|
||||
yield* claim(found.value)
|
||||
return found.value
|
||||
}
|
||||
yield* Effect.sleep(50)
|
||||
}
|
||||
return yield* Effect.fail(
|
||||
last ?? new DaemonError({ kind: "connect", message: "opencode-pty did not become ready" }),
|
||||
)
|
||||
if (found) return found.value
|
||||
yield* Effect.sleep(50)
|
||||
}
|
||||
return yield* Effect.fail(
|
||||
last ?? new DaemonError({ kind: "connect", message: "opencode-pty did not become ready" }),
|
||||
}).pipe(
|
||||
Effect.onError(() =>
|
||||
Effect.sync(() => {
|
||||
child.kill("SIGTERM")
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const connect = Effect.fn("PersistentPty.daemon.connect")(function* (shouldStart: boolean) {
|
||||
if (closed) return yield* Effect.fail(new DaemonError({ kind: "connect", message: "PTY owner scope is closed" }))
|
||||
if (registration) return registration
|
||||
return yield* startup.withPermit(
|
||||
Effect.gen(function* () {
|
||||
if (registration) return registration
|
||||
const found = yield* discover().pipe(
|
||||
Effect.catch((error) => {
|
||||
if (!shouldStart) return Effect.fail(error)
|
||||
if (error.kind === "connect") return start()
|
||||
if (error.kind !== "protocol" || error.pid === undefined) return Effect.fail(error)
|
||||
return terminate(error.pid).pipe(Effect.andThen(start()))
|
||||
if (shouldStart && error.kind === "connect") return start()
|
||||
return Effect.fail(error)
|
||||
}),
|
||||
)
|
||||
if (!owner) yield* claim(found)
|
||||
registration = found
|
||||
return found
|
||||
}),
|
||||
@@ -277,6 +324,27 @@ export const makeDaemonTransport = Effect.fn("PersistentPty.makeDaemonTransport"
|
||||
return yield* Effect.fail(new DaemonError({ kind: "connect", message: "opencode-pty did not stop" }))
|
||||
})
|
||||
|
||||
const handoff = startup.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const current = owner
|
||||
const registered = registration
|
||||
if (!current || !registered) return null
|
||||
const response = yield* Effect.tryPromise({
|
||||
try: (signal) => current.exchange({ op: "prepare_handoff" }, signal),
|
||||
catch: (cause) => failure("response", cause),
|
||||
}).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: Duration.seconds(5),
|
||||
orElse: () =>
|
||||
Effect.fail(new DaemonError({ kind: "response", message: "PTY handoff preparation timed out" })),
|
||||
}),
|
||||
)
|
||||
if (response.type !== "handoff")
|
||||
return yield* Effect.fail(new DaemonError({ kind: "protocol", message: "Expected PTY handoff ticket" }))
|
||||
return { directory, instanceID: registered.instance_id, ticket: response.ticket, expiresAt: response.expires_at }
|
||||
}),
|
||||
)
|
||||
|
||||
const subscribe = Effect.fn("PersistentPty.daemon.subscribe")(function* (
|
||||
id: number,
|
||||
input: Parameters<DaemonTransport["subscribe"]>[1],
|
||||
@@ -296,9 +364,56 @@ export const makeDaemonTransport = Effect.fn("PersistentPty.makeDaemonTransport"
|
||||
return yield* attempt.pipe(Effect.catch((error) => (error.kind === "registration" ? attempt : Effect.fail(error))))
|
||||
})
|
||||
|
||||
return { request, requestIfRunning, shutdown, subscribe } satisfies DaemonTransport
|
||||
// A replacement must own its inherited daemon before the server becomes ready.
|
||||
if (inherited) {
|
||||
if (inherited.expiresAt <= Date.now())
|
||||
return yield* Effect.fail(new DaemonError({ kind: "registration", message: "PTY restart handoff expired" }))
|
||||
const current = yield* discover()
|
||||
if (current.instance_id !== inherited.instanceID)
|
||||
return yield* Effect.fail(new DaemonError({ kind: "registration", message: "PTY restart daemon changed" }))
|
||||
yield* claim(current, inherited.ticket)
|
||||
}
|
||||
|
||||
return { request, requestIfRunning, shutdown, handoff, subscribe } satisfies DaemonTransport
|
||||
})
|
||||
|
||||
async function openOwner(registration: Registration, ticket: string | undefined, signal: AbortSignal) {
|
||||
const socket = net.createConnection({ path: registration.socket })
|
||||
const frames = decoder(socket)
|
||||
const abort = () => socket.destroy()
|
||||
signal.addEventListener("abort", abort, { once: true })
|
||||
const exchange = async (request: object, signal: AbortSignal) => {
|
||||
if (signal.aborted) throw new Error("PTY ownership request interrupted")
|
||||
signal.addEventListener("abort", abort, { once: true })
|
||||
try {
|
||||
socket.write(encode({ token: registration.token, request }))
|
||||
const frame = await frames.next()
|
||||
if (frame.done) throw new Error("PTY daemon closed its ownership connection")
|
||||
const response = decode(frame.value)
|
||||
if (response.type === "error") throw new Error(response.message)
|
||||
return response
|
||||
} finally {
|
||||
signal.removeEventListener("abort", abort)
|
||||
}
|
||||
}
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.once("connect", resolve)
|
||||
socket.once("error", reject)
|
||||
socket.once("close", () => reject(new Error("PTY ownership connection closed")))
|
||||
})
|
||||
const response = await exchange({ op: "own", instance_id: registration.instance_id, ticket }, signal)
|
||||
if (response.type !== "owned") throw new Error("PTY daemon does not support server ownership")
|
||||
socket.unref()
|
||||
return { socket, exchange }
|
||||
} catch (error) {
|
||||
socket.destroy()
|
||||
throw error
|
||||
} finally {
|
||||
signal.removeEventListener("abort", abort)
|
||||
}
|
||||
}
|
||||
|
||||
const oneShot = Effect.fn("PersistentPty.daemon.oneShot")(function* (registration: Registration, request: object) {
|
||||
const payload = yield* Effect.try({
|
||||
try: () => encode({ token: registration.token, request }),
|
||||
@@ -507,29 +622,3 @@ function decode(payload: Uint8Array) {
|
||||
function failure(kind: DaemonError["kind"], cause: unknown) {
|
||||
return new DaemonError({ kind, message: cause instanceof Error ? cause.message : String(cause) })
|
||||
}
|
||||
|
||||
const terminate = Effect.fn("PersistentPty.daemon.terminate-incompatible")(function* (pid: number) {
|
||||
yield* Effect.logWarning("replacing incompatible opencode-pty daemon", { pid })
|
||||
yield* Effect.try({ try: () => process.kill(pid, "SIGTERM"), catch: (cause) => failure("spawn", cause) }).pipe(
|
||||
Effect.catch((error) => (isMissingProcess(error) ? Effect.void : Effect.fail(error))),
|
||||
)
|
||||
const deadline = Date.now() + 2_000
|
||||
while (Date.now() < deadline && processRunning(pid)) yield* Effect.sleep(25)
|
||||
if (!processRunning(pid)) return
|
||||
yield* Effect.try({ try: () => process.kill(pid, "SIGKILL"), catch: (cause) => failure("spawn", cause) }).pipe(
|
||||
Effect.catch((error) => (isMissingProcess(error) ? Effect.void : Effect.fail(error))),
|
||||
)
|
||||
})
|
||||
|
||||
function processRunning(pid: number) {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingProcess(error: DaemonError) {
|
||||
return error.message.includes("ESRCH") || error.message.includes("no such process")
|
||||
}
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
export * as PersistentPty from "./index.js"
|
||||
|
||||
import { createHash } from "node:crypto"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Added, Removed } from "@opencode-ai/schema/persistent-pty"
|
||||
import { Added, Handoff, Removed } from "@opencode-ai/schema/persistent-pty"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Database } from "../database/database.js"
|
||||
import { Pty } from "@opencode-ai/schema/pty"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { ShellSelect } from "../shell/select.js"
|
||||
import {
|
||||
makeDaemonTransport,
|
||||
type DaemonTransport,
|
||||
@@ -22,6 +21,10 @@ import {
|
||||
import { resolveBinary } from "#persistent-pty-binary"
|
||||
|
||||
export type { Role, StreamEvent } from "./daemon.js"
|
||||
export { Handoff } from "@opencode-ai/schema/persistent-pty"
|
||||
|
||||
export const Options = Schema.Struct({ handoff: Schema.optional(Handoff) })
|
||||
export type Options = typeof Options.Type
|
||||
|
||||
export type Info = Pty.Info & {
|
||||
readonly sessionID: Session.ID
|
||||
@@ -66,9 +69,9 @@ export interface Interface {
|
||||
readonly create: (
|
||||
sessionID: Session.ID,
|
||||
input: {
|
||||
readonly command: string
|
||||
readonly command?: string
|
||||
readonly args: readonly string[]
|
||||
readonly cwd: string
|
||||
readonly cwd?: string
|
||||
readonly title: string
|
||||
readonly env: Readonly<Record<string, string>>
|
||||
readonly cols?: number
|
||||
@@ -102,6 +105,7 @@ export interface Interface {
|
||||
readonly snapshot: (id: Pty.ID) => Effect.Effect<Snapshot, NotFoundError | UnavailableError>
|
||||
readonly remove: (id: Pty.ID) => Effect.Effect<void, NotFoundError | UnavailableError>
|
||||
readonly shutdown: () => Effect.Effect<void, UnavailableError>
|
||||
readonly handoff: () => Effect.Effect<Handoff | null, UnavailableError>
|
||||
readonly attach: (
|
||||
id: Pty.ID,
|
||||
input: {
|
||||
@@ -117,218 +121,235 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/PersistentPty") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const database = yield* Database.Service
|
||||
const global = yield* Global.Service
|
||||
const context = yield* Effect.context()
|
||||
const runFork = Effect.runForkWith(context)
|
||||
let binary: Promise<string> | undefined
|
||||
const daemon = yield* makeDaemonTransport(
|
||||
runtimeDirectory(databasePath(database.db)),
|
||||
() =>
|
||||
(binary ??= resolveBinary(global.bin).catch((error) => {
|
||||
binary = undefined
|
||||
throw error
|
||||
})),
|
||||
)
|
||||
const removing = new Set<Pty.ID>()
|
||||
export const configured = (options: Options = {}) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const global = yield* Global.Service
|
||||
const context = yield* Effect.context()
|
||||
const runFork = Effect.runForkWith(context)
|
||||
let binary: Promise<string> | undefined
|
||||
const daemon = yield* makeDaemonTransport(
|
||||
options.handoff?.directory ?? runtimeDirectory(),
|
||||
() =>
|
||||
(binary ??= resolveBinary(global.bin).catch((error) => {
|
||||
binary = undefined
|
||||
throw error
|
||||
})),
|
||||
options.handoff,
|
||||
).pipe(Effect.mapError(unavailable))
|
||||
const removing = new Set<Pty.ID>()
|
||||
|
||||
const list = Effect.fn("PersistentPty.list")(function* (sessionID?: Session.ID) {
|
||||
const response = yield* optionalRequest(daemon, { op: "list" })
|
||||
if (!response) return []
|
||||
if (response.type !== "terminals") return yield* unexpected(response)
|
||||
return response.terminals
|
||||
.map(toInfo)
|
||||
.filter((terminal) => sessionID === undefined || terminal.sessionID === sessionID)
|
||||
})
|
||||
const list = Effect.fn("PersistentPty.list")(function* (sessionID?: Session.ID) {
|
||||
const response = yield* optionalRequest(daemon, { op: "list" })
|
||||
if (!response) return []
|
||||
if (response.type !== "terminals") return yield* unexpected(response)
|
||||
return response.terminals
|
||||
.map(toInfo)
|
||||
.filter((terminal) => sessionID === undefined || terminal.sessionID === sessionID)
|
||||
})
|
||||
|
||||
const get = Effect.fn("PersistentPty.get")(function* (id: Pty.ID) {
|
||||
const found = (yield* list()).find((terminal) => terminal.id === id)
|
||||
if (!found) return yield* new NotFoundError({ ptyID: id })
|
||||
return found
|
||||
})
|
||||
const get = Effect.fn("PersistentPty.get")(function* (id: Pty.ID) {
|
||||
const found = (yield* list()).find((terminal) => terminal.id === id)
|
||||
if (!found) return yield* new NotFoundError({ ptyID: id })
|
||||
return found
|
||||
})
|
||||
|
||||
const create = Effect.fn("PersistentPty.create")(function* (
|
||||
sessionID: Session.ID,
|
||||
input: {
|
||||
readonly command: string
|
||||
readonly args: readonly string[]
|
||||
readonly cwd: string
|
||||
readonly title: string
|
||||
readonly env: Readonly<Record<string, string>>
|
||||
readonly cols?: number
|
||||
readonly rows?: number
|
||||
},
|
||||
) {
|
||||
const response = yield* request(
|
||||
daemon,
|
||||
{
|
||||
op: "create",
|
||||
program: input.command,
|
||||
args: input.args,
|
||||
cwd: input.cwd,
|
||||
title: input.title,
|
||||
group_id: sessionID,
|
||||
env: input.env,
|
||||
cols: input.cols ?? 80,
|
||||
rows: input.rows ?? 24,
|
||||
const create = Effect.fn("PersistentPty.create")(function* (
|
||||
sessionID: Session.ID,
|
||||
input: {
|
||||
readonly command?: string
|
||||
readonly args: readonly string[]
|
||||
readonly cwd?: string
|
||||
readonly title: string
|
||||
readonly env: Readonly<Record<string, string>>
|
||||
readonly cols?: number
|
||||
readonly rows?: number
|
||||
},
|
||||
true,
|
||||
)
|
||||
if (response.type !== "created") return yield* unexpected(response)
|
||||
const terminal = toInfo(response.terminal)
|
||||
yield* bus.publish(Added, { sessionID, terminal })
|
||||
return terminal
|
||||
})
|
||||
|
||||
const write = Effect.fn("PersistentPty.write")(function* (id: Pty.ID, data: string, attachmentID?: string) {
|
||||
yield* get(id)
|
||||
const response = yield* request(daemon, {
|
||||
op: "write",
|
||||
id: fromID(id),
|
||||
attachment_id: attachmentID ?? null,
|
||||
data_base64: Buffer.from(data).toString("base64"),
|
||||
})
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
return undefined
|
||||
})
|
||||
|
||||
const resize = Effect.fn("PersistentPty.resize")(function* (
|
||||
id: Pty.ID,
|
||||
cols: number,
|
||||
rows: number,
|
||||
attachmentID?: string,
|
||||
) {
|
||||
yield* get(id)
|
||||
const response = yield* request(daemon, {
|
||||
op: "resize",
|
||||
id: fromID(id),
|
||||
attachment_id: attachmentID ?? null,
|
||||
cols,
|
||||
rows,
|
||||
})
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
return undefined
|
||||
})
|
||||
|
||||
const control = Effect.fn("PersistentPty.control")(function* (
|
||||
id: Pty.ID,
|
||||
attachmentID: string,
|
||||
cols: number,
|
||||
rows: number,
|
||||
) {
|
||||
yield* get(id)
|
||||
const response = yield* request(daemon, {
|
||||
op: "control",
|
||||
id: fromID(id),
|
||||
attachment_id: attachmentID,
|
||||
cols,
|
||||
rows,
|
||||
})
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
return undefined
|
||||
})
|
||||
|
||||
const input = Effect.fn("PersistentPty.input")(function* (
|
||||
id: Pty.ID,
|
||||
attachmentID: string,
|
||||
cols: number,
|
||||
rows: number,
|
||||
data: Uint8Array,
|
||||
) {
|
||||
yield* get(id)
|
||||
const response = yield* request(daemon, {
|
||||
op: "input",
|
||||
id: fromID(id),
|
||||
attachment_id: attachmentID,
|
||||
cols,
|
||||
rows,
|
||||
data_base64: Buffer.from(data).toString("base64"),
|
||||
})
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
return undefined
|
||||
})
|
||||
|
||||
const snapshot = Effect.fn("PersistentPty.snapshot")(function* (id: Pty.ID) {
|
||||
yield* get(id)
|
||||
const response = yield* request(daemon, { op: "snapshot", id: fromID(id) })
|
||||
if (response.type !== "snapshot") return yield* unexpected(response)
|
||||
return {
|
||||
info: toInfo(response.terminal),
|
||||
text: response.text,
|
||||
checkpoint: Buffer.from(response.checkpoint_base64, "base64"),
|
||||
cursor: { x: response.cursor_x, y: response.cursor_y },
|
||||
}
|
||||
})
|
||||
|
||||
const remove = Effect.fn("PersistentPty.remove")(function* (id: Pty.ID) {
|
||||
const terminal = yield* get(id)
|
||||
const response = yield* request(daemon, { op: "terminate", id: fromID(id) })
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
yield* bus.publish(Removed, { sessionID: terminal.sessionID, ptyID: id })
|
||||
return undefined
|
||||
})
|
||||
|
||||
const shutdown = Effect.fn("PersistentPty.shutdown")(function* () {
|
||||
const response = yield* daemon.shutdown.pipe(Effect.mapError(unavailable))
|
||||
if (!response) return
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
})
|
||||
|
||||
const removeVisibleExit = (id: Pty.ID) => {
|
||||
if (removing.has(id)) return
|
||||
removing.add(id)
|
||||
runFork(
|
||||
remove(id).pipe(
|
||||
Effect.catchTags({
|
||||
"PersistentPty.NotFoundError": () => Effect.void,
|
||||
"PersistentPty.UnavailableError": (error) =>
|
||||
Effect.logWarning("failed to remove visible exited terminal", { id, error: error.message }),
|
||||
}),
|
||||
Effect.ensuring(Effect.sync(() => removing.delete(id))),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const attach = Effect.fn("PersistentPty.attach")(function* (
|
||||
id: Pty.ID,
|
||||
input: {
|
||||
readonly cursor: number
|
||||
readonly attachmentID: string
|
||||
readonly role: Role
|
||||
readonly takeover?: boolean
|
||||
readonly onEvent: (event: StreamEvent) => void
|
||||
readonly onEnd: () => void
|
||||
},
|
||||
) {
|
||||
yield* get(id)
|
||||
const attachment = yield* daemon
|
||||
.subscribe(fromID(id), {
|
||||
...input,
|
||||
onEvent: (event) => {
|
||||
if (event.type === "exited") removeVisibleExit(id)
|
||||
input.onEvent(event)
|
||||
) {
|
||||
const response = yield* request(
|
||||
daemon,
|
||||
{
|
||||
op: "create",
|
||||
program: input.command ?? ShellSelect.environment(global.bin),
|
||||
args: input.args,
|
||||
cwd: input.cwd ?? path.resolve("/"),
|
||||
title: input.title,
|
||||
group_id: sessionID,
|
||||
env: input.env,
|
||||
cols: input.cols ?? 80,
|
||||
rows: input.rows ?? 24,
|
||||
},
|
||||
true,
|
||||
)
|
||||
if (response.type !== "created") return yield* unexpected(response)
|
||||
const terminal = toInfo(response.terminal)
|
||||
yield* bus.publish(Added, { sessionID, terminal })
|
||||
return terminal
|
||||
})
|
||||
|
||||
const write = Effect.fn("PersistentPty.write")(function* (id: Pty.ID, data: string, attachmentID?: string) {
|
||||
yield* get(id)
|
||||
const response = yield* request(daemon, {
|
||||
op: "write",
|
||||
id: fromID(id),
|
||||
attachment_id: attachmentID ?? null,
|
||||
data_base64: Buffer.from(data).toString("base64"),
|
||||
})
|
||||
.pipe(Effect.mapError(unavailable))
|
||||
return {
|
||||
info: toInfo(attachment.terminal),
|
||||
role: attachment.role,
|
||||
generation: attachment.generation,
|
||||
replay: attachment.replay,
|
||||
activate: attachment.activate,
|
||||
detach: attachment.detach,
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
return undefined
|
||||
})
|
||||
|
||||
const resize = Effect.fn("PersistentPty.resize")(function* (
|
||||
id: Pty.ID,
|
||||
cols: number,
|
||||
rows: number,
|
||||
attachmentID?: string,
|
||||
) {
|
||||
yield* get(id)
|
||||
const response = yield* request(daemon, {
|
||||
op: "resize",
|
||||
id: fromID(id),
|
||||
attachment_id: attachmentID ?? null,
|
||||
cols,
|
||||
rows,
|
||||
})
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
return undefined
|
||||
})
|
||||
|
||||
const control = Effect.fn("PersistentPty.control")(function* (
|
||||
id: Pty.ID,
|
||||
attachmentID: string,
|
||||
cols: number,
|
||||
rows: number,
|
||||
) {
|
||||
yield* get(id)
|
||||
const response = yield* request(daemon, {
|
||||
op: "control",
|
||||
id: fromID(id),
|
||||
attachment_id: attachmentID,
|
||||
cols,
|
||||
rows,
|
||||
})
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
return undefined
|
||||
})
|
||||
|
||||
const input = Effect.fn("PersistentPty.input")(function* (
|
||||
id: Pty.ID,
|
||||
attachmentID: string,
|
||||
cols: number,
|
||||
rows: number,
|
||||
data: Uint8Array,
|
||||
) {
|
||||
yield* get(id)
|
||||
const response = yield* request(daemon, {
|
||||
op: "input",
|
||||
id: fromID(id),
|
||||
attachment_id: attachmentID,
|
||||
cols,
|
||||
rows,
|
||||
data_base64: Buffer.from(data).toString("base64"),
|
||||
})
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
return undefined
|
||||
})
|
||||
|
||||
const snapshot = Effect.fn("PersistentPty.snapshot")(function* (id: Pty.ID) {
|
||||
yield* get(id)
|
||||
const response = yield* request(daemon, { op: "snapshot", id: fromID(id) })
|
||||
if (response.type !== "snapshot") return yield* unexpected(response)
|
||||
return {
|
||||
info: toInfo(response.terminal),
|
||||
text: response.text,
|
||||
checkpoint: Buffer.from(response.checkpoint_base64, "base64"),
|
||||
cursor: { x: response.cursor_x, y: response.cursor_y },
|
||||
}
|
||||
})
|
||||
|
||||
const remove = Effect.fn("PersistentPty.remove")(function* (id: Pty.ID) {
|
||||
const terminal = yield* get(id)
|
||||
const response = yield* request(daemon, { op: "terminate", id: fromID(id) })
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
yield* bus.publish(Removed, { sessionID: terminal.sessionID, ptyID: id })
|
||||
return undefined
|
||||
})
|
||||
|
||||
const shutdown = Effect.fn("PersistentPty.shutdown")(function* () {
|
||||
const response = yield* daemon.shutdown.pipe(Effect.mapError(unavailable))
|
||||
if (!response) return
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
})
|
||||
|
||||
const handoff = Effect.fn("PersistentPty.handoff")(() => daemon.handoff.pipe(Effect.mapError(unavailable)))
|
||||
|
||||
const removeVisibleExit = (id: Pty.ID) => {
|
||||
if (removing.has(id)) return
|
||||
removing.add(id)
|
||||
runFork(
|
||||
remove(id).pipe(
|
||||
Effect.catchTags({
|
||||
"PersistentPty.NotFoundError": () => Effect.void,
|
||||
"PersistentPty.UnavailableError": (error) =>
|
||||
Effect.logWarning("failed to remove visible exited terminal", { id, error: error.message }),
|
||||
}),
|
||||
Effect.ensuring(Effect.sync(() => removing.delete(id))),
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({ list, get, create, write, resize, control, input, snapshot, remove, shutdown, attach })
|
||||
}),
|
||||
)
|
||||
const attach = Effect.fn("PersistentPty.attach")(function* (
|
||||
id: Pty.ID,
|
||||
input: {
|
||||
readonly cursor: number
|
||||
readonly attachmentID: string
|
||||
readonly role: Role
|
||||
readonly takeover?: boolean
|
||||
readonly onEvent: (event: StreamEvent) => void
|
||||
readonly onEnd: () => void
|
||||
},
|
||||
) {
|
||||
yield* get(id)
|
||||
const attachment = yield* daemon
|
||||
.subscribe(fromID(id), {
|
||||
...input,
|
||||
onEvent: (event) => {
|
||||
if (event.type === "exited") removeVisibleExit(id)
|
||||
input.onEvent(event)
|
||||
},
|
||||
})
|
||||
.pipe(Effect.mapError(unavailable))
|
||||
return {
|
||||
info: toInfo(attachment.terminal),
|
||||
role: attachment.role,
|
||||
generation: attachment.generation,
|
||||
replay: attachment.replay,
|
||||
activate: attachment.activate,
|
||||
detach: attachment.detach,
|
||||
}
|
||||
})
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [Bus.node, Database.node, Global.node] })
|
||||
return Service.of({
|
||||
list,
|
||||
get,
|
||||
create,
|
||||
write,
|
||||
resize,
|
||||
control,
|
||||
input,
|
||||
snapshot,
|
||||
remove,
|
||||
shutdown,
|
||||
handoff,
|
||||
attach,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const layer = configured()
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [Bus.node, Global.node] })
|
||||
|
||||
const request = (daemon: DaemonTransport, value: object, start = false) =>
|
||||
daemon.request(value, start).pipe(Effect.mapError(unavailable))
|
||||
@@ -342,17 +363,7 @@ const unexpected = (response: WireResponse) =>
|
||||
const unavailable = (error: unknown) =>
|
||||
new UnavailableError({ message: error instanceof Error ? error.message : String(error) })
|
||||
|
||||
function databasePath(db: Database.Interface["db"]) {
|
||||
const client: unknown = db.$client
|
||||
if ((typeof client !== "object" && typeof client !== "function") || client === null || !("config" in client))
|
||||
return undefined
|
||||
const config = client.config
|
||||
if (typeof config !== "object" || config === null || !("filename" in config)) return undefined
|
||||
if (typeof config.filename !== "string" || config.filename === ":memory:") return undefined
|
||||
return path.resolve(config.filename)
|
||||
}
|
||||
|
||||
const runtimeDirectory = (databasePath?: string) => {
|
||||
const runtimeDirectory = () => {
|
||||
const root =
|
||||
process.env.OPENCODE_PTY_RUNTIME_DIR ??
|
||||
(process.env.XDG_RUNTIME_DIR
|
||||
@@ -361,8 +372,7 @@ const runtimeDirectory = (databasePath?: string) => {
|
||||
os.tmpdir(),
|
||||
`opencode-pty-${typeof process.getuid === "function" ? process.getuid() : process.env.USER || "unknown"}`,
|
||||
))
|
||||
const identity = databasePath ?? `memory:${crypto.randomUUID()}`
|
||||
return path.join(root, createHash("sha256").update(identity).digest("hex").slice(0, 16))
|
||||
return path.join(root, crypto.randomUUID())
|
||||
}
|
||||
|
||||
function toInfo(value: WireTerminal): Info {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
export * as InstancePlugins from "./instance.js"
|
||||
|
||||
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Context, Layer } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import type { Versioned } from "../plugin.js"
|
||||
|
||||
/**
|
||||
* Holds the plugins one instance is born with. Unlike the host-global
|
||||
* `SdkPlugins` store, this list is a birth argument of a single instance:
|
||||
* `Instance.layer` binds it through the replacement mechanism, so two
|
||||
* instances in one process can carry different plugins. The list is immutable
|
||||
* for the instance's lifetime; runtime dynamism lives inside plugins through
|
||||
* the container transform/reload APIs.
|
||||
*
|
||||
* Limitations, both shared with `SdkPlugins`: `vcs` marker declarations are
|
||||
* not seen by `ProjectMarkers` (it is global and runs during project
|
||||
* resolution, before the instance exists), and config plugin operations may
|
||||
* disable instance plugins by id.
|
||||
*/
|
||||
export type List = readonly Plugin[]
|
||||
|
||||
export interface Interface {
|
||||
readonly all: () => readonly Versioned[]
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/InstancePlugins") {}
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer: Layer.succeed(Service, Service.of({ all: () => [] })),
|
||||
deps: [],
|
||||
})
|
||||
|
||||
// The constant version is load-bearing: the plugin registry treats an
|
||||
// unchanged (id, version) pair as the same plugin across activations, which
|
||||
// is only correct because a bound list never changes after creation.
|
||||
// `source: "sdk"` means host-contributed; an instance list is the
|
||||
// per-instance form of the same channel.
|
||||
export function bound(plugins: List) {
|
||||
const duplicates = plugins.filter((plugin, index) => plugins.findIndex((other) => other.id === plugin.id) !== index)
|
||||
if (duplicates.length > 0) {
|
||||
throw new Error(`duplicate instance plugin ids: ${duplicates.map((plugin) => plugin.id).join(", ")}`)
|
||||
}
|
||||
const stamped = plugins.map(
|
||||
(plugin): Versioned => ({ ...plugin, version: "instance", source: { type: "sdk" } }),
|
||||
)
|
||||
return Layer.succeed(Service, Service.of({ all: () => stamped }))
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Plugin } from "../plugin.js"
|
||||
import { InstancePlugins } from "./instance.js"
|
||||
import { PluginInternal } from "./internal.js"
|
||||
import { PluginModule } from "./module.js"
|
||||
import { SdkPlugins } from "./sdk.js"
|
||||
@@ -85,6 +86,7 @@ export const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* Plugin.Service
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
const instance = yield* InstancePlugins.Service
|
||||
const sources = yield* ConfigPluginSource.Service
|
||||
const bus = yield* Bus.Service
|
||||
const ready = yield* Latch.make()
|
||||
@@ -93,10 +95,13 @@ export const layer = Layer.effect(
|
||||
const activate = Effect.fn("PluginSupervisor.activate")(function* () {
|
||||
// Resolve OpenCode's internal plugins with their privileged Location services.
|
||||
const internal = yield* PluginInternal.list()
|
||||
// Combine internal plugins with host-contributed SDK plugins in boot order.
|
||||
// Combine internal plugins with host-contributed plugins in boot order.
|
||||
// Instance-bound plugins come last: later activation can override earlier
|
||||
// container writes, so the instance's explicit choices win over globals.
|
||||
const pre = [
|
||||
...internal.pre.map((plugin) => ({ ...plugin, version: "internal", source: { type: "builtin" as const } })),
|
||||
...sdk.all(),
|
||||
...instance.all(),
|
||||
]
|
||||
const post = internal.post.map((plugin) => ({
|
||||
...plugin,
|
||||
@@ -138,6 +143,7 @@ export const layer = Layer.effect(
|
||||
const nodeDeps = [
|
||||
Plugin.node,
|
||||
SdkPlugins.node,
|
||||
InstancePlugins.node,
|
||||
ConfigPluginSource.node,
|
||||
Bus.node,
|
||||
Npm.node,
|
||||
|
||||
@@ -77,29 +77,29 @@ export const layer = Layer.effect(
|
||||
const releaseOnCommit = (sessionID: SessionSchema.ID) => ({
|
||||
commit: () => store.release(sessionID),
|
||||
})
|
||||
function drain(
|
||||
const drain = Effect.fnUntraced(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
force: boolean,
|
||||
continuation?: SessionRunner.Continuation,
|
||||
promotable: SessionInbox.Promotable = "input",
|
||||
): Effect.Effect<void, SessionRunner.RunError> {
|
||||
return Effect.gen(function* () {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
|
||||
const result = yield* SessionRunner.Service.use((runner) =>
|
||||
runner.drain({ sessionID, force, continuation, promotable }),
|
||||
).pipe(
|
||||
Effect.provide(locations.get(session.location)),
|
||||
Effect.tapCause((cause) =>
|
||||
Cause.hasInterruptsOnly(cause)
|
||||
? Effect.void
|
||||
: Effect.logError("Failed to drain Session", cause).pipe(Effect.annotateLogs({ sessionID })),
|
||||
),
|
||||
)
|
||||
if (result._tag === "Complete") return
|
||||
return yield* drain(sessionID, false, result.continuation, promotable)
|
||||
): Effect.fn.Return<void, SessionRunner.RunError> {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
|
||||
const result = yield* SessionRunner.Service.use((runner) =>
|
||||
runner.drain({ sessionID, force, continuation, promotable }),
|
||||
).pipe(
|
||||
Effect.provide(locations.get(session.location)),
|
||||
Effect.tapCause((cause) =>
|
||||
Cause.hasInterruptsOnly(cause)
|
||||
? Effect.void
|
||||
: Effect.logError("Failed to drain Session", cause).pipe(Effect.annotateLogs({ sessionID })),
|
||||
),
|
||||
)
|
||||
return yield* SessionRunner.DrainResult.$match(result, {
|
||||
Complete: () => Effect.void,
|
||||
Moved: (result) => drain(sessionID, false, result.continuation, promotable),
|
||||
})
|
||||
}
|
||||
})
|
||||
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError, InterruptReason>({
|
||||
started: (sessionID) =>
|
||||
reportLifecycle(
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type WebSocketConnection,
|
||||
type WebSocketConnector,
|
||||
} from "@opencode-ai/ai/route"
|
||||
import { AIError, TransportReason, type TransportOperation } from "@opencode-ai/ai"
|
||||
import { AIError, AIErrorReason, TransportError, type TransportOperation } from "@opencode-ai/ai"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
import { Cause, Clock, Context, Effect, Fiber, Layer, Metric, Queue, Scope, Semaphore, Stream } from "effect"
|
||||
import { Socket } from "effect/unstable/socket"
|
||||
@@ -63,38 +63,29 @@ export interface Interface {
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionModelTransport") {}
|
||||
|
||||
const transportError = (
|
||||
method: string,
|
||||
message: string,
|
||||
input: {
|
||||
readonly operation: TransportOperation
|
||||
readonly url?: string
|
||||
readonly code?: string
|
||||
readonly phase?: TransportReason["phase"]
|
||||
readonly delivery?: TransportReason["delivery"]
|
||||
readonly phase?: TransportError["phase"]
|
||||
readonly delivery?: TransportError["delivery"]
|
||||
},
|
||||
) =>
|
||||
new AIError({
|
||||
module: "SessionModelTransport",
|
||||
method,
|
||||
reason: new TransportReason({ message, transport: "websocket", ...input }),
|
||||
reason: new TransportError({ message, transport: "websocket", ...input }),
|
||||
})
|
||||
|
||||
const annotate = (
|
||||
error: AIError,
|
||||
input: { readonly phase: TransportReason["phase"]; readonly delivery: TransportReason["delivery"] },
|
||||
input: { readonly phase: TransportError["phase"]; readonly delivery: TransportError["delivery"] },
|
||||
) => {
|
||||
if (error.reason._tag !== "Transport") return error
|
||||
return new AIError({
|
||||
module: error.module,
|
||||
method: error.method,
|
||||
reason: new TransportReason({
|
||||
message: error.reason.message,
|
||||
transport: error.reason.transport,
|
||||
operation: error.reason.operation,
|
||||
code: error.reason.code,
|
||||
url: error.reason.url,
|
||||
http: error.reason.http,
|
||||
recovery: error.reason.recovery,
|
||||
reason: new TransportError({
|
||||
...error.reason,
|
||||
message: error.message,
|
||||
cause: error.reason.cause,
|
||||
...input,
|
||||
}),
|
||||
})
|
||||
@@ -135,7 +126,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
Queue.failCauseUnsafe(
|
||||
channel.active.queue,
|
||||
Cause.fail(
|
||||
transportError("close", "Session WebSocket closed", {
|
||||
transportError("Session WebSocket closed", {
|
||||
operation: "read",
|
||||
code: "close",
|
||||
phase: "close",
|
||||
@@ -184,7 +175,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
)
|
||||
if (owner.closed) {
|
||||
yield* connection.close
|
||||
return yield* transportError("open", "Session WebSocket owner closed while connecting", {
|
||||
return yield* transportError("Session WebSocket owner closed while connecting", {
|
||||
operation: "request",
|
||||
code: "owner-closed",
|
||||
phase: "connect",
|
||||
@@ -204,7 +195,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
Effect.gen(function* () {
|
||||
const active = channel.active
|
||||
if (!active)
|
||||
return yield* transportError("receive", "WebSocket data arrived without an active exchange", {
|
||||
return yield* transportError("WebSocket data arrived without an active exchange", {
|
||||
url: exchange.connect.url,
|
||||
operation: "read",
|
||||
code: "idle-data",
|
||||
@@ -212,14 +203,14 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
})
|
||||
active.lifecycle.delivery = "provider-observed"
|
||||
if (typeof message !== "string")
|
||||
return yield* transportError("receive", "Unsupported binary WebSocket frame", {
|
||||
return yield* transportError("Unsupported binary WebSocket frame", {
|
||||
url: exchange.connect.url,
|
||||
operation: "read",
|
||||
code: "message",
|
||||
phase: "receive",
|
||||
})
|
||||
if (Queue.offerUnsafe(active.queue, message)) return undefined
|
||||
return yield* transportError("receive", "Session WebSocket inbound queue overflow", {
|
||||
return yield* transportError("Session WebSocket inbound queue overflow", {
|
||||
url: exchange.connect.url,
|
||||
operation: "read",
|
||||
code: "queue-overflow",
|
||||
@@ -271,7 +262,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
lifecycle: { delivery: Delivery },
|
||||
) {
|
||||
if (owner.closed)
|
||||
return yield* transportError("start", "Session WebSocket owner is closed", {
|
||||
return yield* transportError("Session WebSocket owner is closed", {
|
||||
operation: "request",
|
||||
code: "owner-closed",
|
||||
phase: "queue",
|
||||
@@ -349,7 +340,14 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
Effect.result,
|
||||
)
|
||||
if (sent._tag === "Failure") {
|
||||
const failure = sent.failure
|
||||
const failure = new AIError({
|
||||
reason: AIErrorReason.make({
|
||||
...sent.failure.reason,
|
||||
message: sent.failure.message,
|
||||
cause: sent.failure.reason.cause,
|
||||
http: sent.failure.reason.http ?? channel.connection.http,
|
||||
}),
|
||||
})
|
||||
const notSent = failure.reason._tag === "Transport" && failure.reason.delivery === "not-sent"
|
||||
yield* closeChannel(owner, channel)
|
||||
if (notSent) {
|
||||
@@ -369,7 +367,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
duration: IDLE_TIMEOUT,
|
||||
orElse: () =>
|
||||
Stream.fail(
|
||||
transportError("receive", "Timed out waiting for WebSocket data", {
|
||||
transportError("Timed out waiting for WebSocket data", {
|
||||
url: exchange.connect.url,
|
||||
operation: "read",
|
||||
code: "idle-timeout",
|
||||
@@ -407,14 +405,14 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
channel.checkpoint = undefined
|
||||
channel.pending = undefined
|
||||
const error = terminal
|
||||
? transportError("receive", "WebSocket data arrived after the terminal event", {
|
||||
? transportError("WebSocket data arrived after the terminal event", {
|
||||
url: exchange.connect.url,
|
||||
operation: "read",
|
||||
code: "idle-data",
|
||||
phase: "receive",
|
||||
delivery: "accepted",
|
||||
})
|
||||
: transportError("execute", "Session WebSocket exchange did not reach a terminal event", {
|
||||
: transportError("Session WebSocket exchange did not reach a terminal event", {
|
||||
url: exchange.connect.url,
|
||||
operation: "read",
|
||||
code: "incomplete",
|
||||
@@ -450,27 +448,30 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
channel.checkpoint = channel.pending.checkpoint
|
||||
channel.pending = undefined
|
||||
})
|
||||
return { frames, complete }
|
||||
return { frames, complete, http: channel.connection.http }
|
||||
})
|
||||
|
||||
const bind = (sessionID: SessionSchema.ID): WebSocketChannelExecutor => ({
|
||||
execute: (exchange) => {
|
||||
const owner = state(sessionID)
|
||||
const lifecycle = { delivery: "queued" as Delivery }
|
||||
let complete = Effect.void
|
||||
let execution: WebSocketChannelExecution | undefined
|
||||
return Effect.succeed({
|
||||
get http() {
|
||||
return execution?.http
|
||||
},
|
||||
frames: Stream.unwrap(
|
||||
Effect.acquireRelease(owner.lock.take(1), () => owner.lock.release(1), { interruptible: true }).pipe(
|
||||
Effect.andThen(start(owner, exchange, lifecycle)),
|
||||
Effect.tap((execution) =>
|
||||
Effect.tap((started) =>
|
||||
Effect.sync(() => {
|
||||
complete = execution.complete
|
||||
execution = started
|
||||
}),
|
||||
),
|
||||
Effect.map((execution) => execution.frames),
|
||||
Effect.map((started) => started.frames),
|
||||
),
|
||||
),
|
||||
complete: Effect.suspend(() => complete),
|
||||
complete: Effect.suspend(() => execution?.complete ?? Effect.void),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -85,14 +85,9 @@ const layer = Layer.effect(
|
||||
const promotable = input.promotable ?? "input"
|
||||
if (!force && !continuing) {
|
||||
const pending = yield* SessionInbox.nextPromotable(db, sessionID, "input")
|
||||
if (
|
||||
!pending ||
|
||||
(pending.delivery === "queue" &&
|
||||
promotable === "steer" &&
|
||||
pending.type !== "compaction" &&
|
||||
pending.type !== "move")
|
||||
)
|
||||
return DrainResult.Complete()
|
||||
if (!pending) return DrainResult.Complete()
|
||||
const control = pending.type === "compaction" || pending.type === "move"
|
||||
if (promotable === "steer" && pending.delivery === "queue" && !control) return DrainResult.Complete()
|
||||
}
|
||||
yield* plugins.flush
|
||||
yield* settleStaleToolCalls(sessionID)
|
||||
@@ -263,29 +258,33 @@ const layer = Layer.effect(
|
||||
: Effect.succeed(false),
|
||||
),
|
||||
})
|
||||
if (outcome._tag === "Completed") return outcome.needsContinuation
|
||||
if (outcome._tag === "Retry" || outcome._tag === "Continue") {
|
||||
yield* retry({ cause: outcome.cause, error: outcome.error, assistantMessageID }).pipe(
|
||||
Pull.catchDone(() =>
|
||||
Effect.gen(function* () {
|
||||
if (outcome._tag === "Retry")
|
||||
yield* bus.publish(SessionEvent.Step.Failed, { sessionID, assistantMessageID, error: outcome.error })
|
||||
return yield* outcome.cause
|
||||
}),
|
||||
const completed = yield* SessionStep.Outcome.$match(outcome, {
|
||||
Completed: (outcome) => Effect.succeed(outcome.needsContinuation),
|
||||
Retry: (outcome) =>
|
||||
retry({ cause: outcome.cause, error: outcome.error, assistantMessageID }).pipe(
|
||||
Pull.catchDone(() =>
|
||||
bus
|
||||
.publish(SessionEvent.Step.Failed, { sessionID, assistantMessageID, error: outcome.error })
|
||||
.pipe(Effect.andThen(outcome.cause)),
|
||||
),
|
||||
Effect.asVoid,
|
||||
),
|
||||
)
|
||||
if (outcome._tag === "Continue") {
|
||||
Continue: Effect.fnUntraced(function* (outcome) {
|
||||
yield* retry({ cause: outcome.cause, error: outcome.error, assistantMessageID }).pipe(
|
||||
Pull.catchDone(() => outcome.cause),
|
||||
)
|
||||
yield* bus.publish(SessionEvent.Synthetic, { sessionID, text: CONTINUE_AFTER_INCOMPLETE_STREAM })
|
||||
assistantMessageID = SessionMessage.ID.create()
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (outcome._tag === "Compacted") {
|
||||
recoverOverflow = false
|
||||
assistantMessageID = SessionMessage.ID.create()
|
||||
continue
|
||||
}
|
||||
recoverContinuation = false
|
||||
}),
|
||||
Compacted: Effect.fnUntraced(function* () {
|
||||
recoverOverflow = false
|
||||
assistantMessageID = SessionMessage.ID.create()
|
||||
}),
|
||||
RecoverFull: Effect.fnUntraced(function* () {
|
||||
recoverContinuation = false
|
||||
}),
|
||||
})
|
||||
if (completed !== undefined) return completed
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface Input {
|
||||
}
|
||||
|
||||
export function isRetryable(error: AIError) {
|
||||
const override = "http" in error.reason ? error.reason.http?.response?.headers["x-should-retry"] : undefined
|
||||
const override = error.reason.http?.headers["x-should-retry"]
|
||||
if (override === "true") return true
|
||||
if (override === "false") return false
|
||||
switch (error.reason._tag) {
|
||||
|
||||
@@ -2,7 +2,7 @@ export * as SessionStep from "./step.js"
|
||||
|
||||
import {
|
||||
AIError,
|
||||
InvalidProviderOutputReason,
|
||||
InvalidProviderOutputError,
|
||||
LLMClient,
|
||||
LLMEvent,
|
||||
isContextOverflowFailure,
|
||||
@@ -36,7 +36,7 @@ export type Outcome = Data.TaggedEnum<{
|
||||
RecoverFull: {}
|
||||
Compacted: {}
|
||||
}>
|
||||
const Outcome = Data.taggedEnum<Outcome>()
|
||||
export const Outcome = Data.taggedEnum<Outcome>()
|
||||
|
||||
interface Input {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
@@ -127,11 +127,11 @@ export const make = Effect.gen(function* () {
|
||||
Effect.gen(function* () {
|
||||
const stream = yield* restore(providerStream).pipe(Effect.exit)
|
||||
const streamFailure = Option.getOrUndefined(Exit.findErrorOption(stream))
|
||||
const streamInterrupted = stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)
|
||||
const streamInterrupted = Exit.hasInterrupts(stream)
|
||||
if (!overflowFailure && publisher.hasStarted()) yield* publisher.streamed()
|
||||
if (streamInterrupted) yield* interruptTools
|
||||
const joined = yield* restore(Fiber.awaitAll(toolRuns.map((run) => run.fiber))).pipe(Effect.exit)
|
||||
if (joined._tag === "Failure") yield* interruptTools
|
||||
if (Exit.isFailure(joined)) yield* interruptTools
|
||||
const tools = classifyToolExits(
|
||||
joined,
|
||||
toolRuns.map((run) => run.call),
|
||||
@@ -147,13 +147,11 @@ export const make = Effect.gen(function* () {
|
||||
if (overflowFailure) yield* publisher.publish(overflowFailure)
|
||||
const recorded = publisher.record()
|
||||
const unknownFinish =
|
||||
stream._tag === "Success" && recorded.finish?.finish === "unknown"
|
||||
Exit.isSuccess(stream) && recorded.finish?.finish === "unknown"
|
||||
? new AIError({
|
||||
module: "session",
|
||||
method: "stream",
|
||||
reason: new InvalidProviderOutputReason({
|
||||
classification: "incomplete-stream",
|
||||
reason: new InvalidProviderOutputError({
|
||||
message: "The provider response ended with an unknown finish reason.",
|
||||
classification: "incomplete-stream",
|
||||
}),
|
||||
})
|
||||
: undefined
|
||||
@@ -193,7 +191,7 @@ export const make = Effect.gen(function* () {
|
||||
if (interrupted) yield* publisher.failAssistant(STEP_INTERRUPTED)
|
||||
|
||||
// All local fibers have joined; only provider-hosted results can still be missing.
|
||||
if (llmError || (stream._tag === "Success" && !recorded.providerFailed)) {
|
||||
if (llmError || (Exit.isSuccess(stream) && !recorded.providerFailed)) {
|
||||
const missing = yield* publisher.failUnsettledTools(RESULT_MISSING, "hosted")
|
||||
if (missing && !llmError && !recorded.finish) yield* publisher.failAssistant(RESULT_MISSING)
|
||||
}
|
||||
@@ -236,10 +234,10 @@ export const make = Effect.gen(function* () {
|
||||
)
|
||||
return Outcome.Continue({ cause: llmFailure, error: llmError })
|
||||
|
||||
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
|
||||
if (Exit.isFailure(stream)) return yield* Effect.failCause(stream.cause)
|
||||
if (tools.declines.length > 0) return yield* Effect.interrupt
|
||||
if (tools.interrupted && tools.failure) return yield* Effect.failCause(tools.failure)
|
||||
if (tools.interrupted && joined._tag === "Failure") return yield* Effect.failCause(joined.cause)
|
||||
if (tools.interrupted && Exit.isFailure(joined)) return yield* Effect.failCause(joined.cause)
|
||||
if (record.failure) return yield* new StepFailedError({ error: record.failure })
|
||||
return Outcome.Completed({
|
||||
needsContinuation: !input.toolsDisabled && record.needsContinuation,
|
||||
@@ -267,18 +265,17 @@ const classifyToolExits = (
|
||||
settled: Exit.Exit<Array<Exit.Exit<void, SessionModelRequest.ExecuteError>>>,
|
||||
calls: ReadonlyArray<ToolCall>,
|
||||
) => {
|
||||
const exits = settled._tag === "Success" ? settled.value : []
|
||||
const exits = Exit.isSuccess(settled) ? settled.value : []
|
||||
const declines = exits.flatMap((exit, index) =>
|
||||
exit._tag === "Failure"
|
||||
Exit.isFailure(exit)
|
||||
? exit.cause.reasons.flatMap((reason) =>
|
||||
Cause.isFailReason(reason) && isDecline(reason.error) ? [{ call: calls[index], reason: reason.error }] : [],
|
||||
)
|
||||
: [],
|
||||
)
|
||||
const causes =
|
||||
settled._tag === "Failure"
|
||||
? [settled.cause]
|
||||
: exits.flatMap((exit) => (exit._tag === "Failure" ? [exit.cause] : []))
|
||||
const causes = Exit.isFailure(settled)
|
||||
? [settled.cause]
|
||||
: exits.flatMap((exit) => (Exit.isFailure(exit) ? [exit.cause] : []))
|
||||
const failure = causes
|
||||
.flatMap((cause) => {
|
||||
if (Cause.hasInterrupts(cause)) return []
|
||||
|
||||
@@ -59,7 +59,6 @@ export function toSessionError(cause: unknown): SessionError.Error {
|
||||
}
|
||||
|
||||
function providerError(type: string, reason: AIError["reason"]): SessionError.Error {
|
||||
const status =
|
||||
("http" in reason ? reason.http?.response?.status : undefined) ?? ("status" in reason ? reason.status : undefined)
|
||||
const status = reason.http?.status
|
||||
return { type, message: reason.message, ...(status === undefined ? {} : { status }) }
|
||||
}
|
||||
|
||||
@@ -329,7 +329,9 @@ function expandKnownDirectory(value: string) {
|
||||
// Unknown shell expressions cannot be resolved safely during permission analysis.
|
||||
if (value.includes("$") || value.includes("`") || value.startsWith("(")) return
|
||||
if (value === "~") return os.homedir()
|
||||
if (value.startsWith("~/") || value.startsWith("~\\")) return path.join(os.homedir(), value.slice(2))
|
||||
if (value.startsWith("~/") || (process.platform === "win32" && value.startsWith("~\\"))) {
|
||||
return path.join(os.homedir(), value.slice(2))
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
|
||||
@@ -167,6 +167,11 @@ export function args(file: string, command: string) {
|
||||
return ["-c", command]
|
||||
}
|
||||
|
||||
// Resolve afresh so removing a shell does not leave terminals using a stale cached path.
|
||||
export function environment(bin?: string, filter?: { compatible?: boolean }) {
|
||||
return select(process.env.SHELL, undefined, filter, bin) ?? fallback(bin)
|
||||
}
|
||||
|
||||
let defaultConfigured: { bin?: string; value: string } | undefined
|
||||
let defaultCompatible: { bin?: string; value: string } | undefined
|
||||
|
||||
@@ -176,7 +181,7 @@ export function resolve(input: ResolveInput, configShell?: string, options?: Opt
|
||||
if (options?.gitbash) return select(process.env.SHELL, options, filter, bin)
|
||||
const cached = input.priority === "compat" ? defaultCompatible : defaultConfigured
|
||||
if (cached && cached.bin === bin) return cached.value
|
||||
const value = select(process.env.SHELL, undefined, filter, bin) ?? fallback(bin)
|
||||
const value = environment(bin, filter)
|
||||
if (input.priority === "compat") defaultCompatible = { bin, value }
|
||||
if (input.priority === "config") defaultConfigured = { bin, value }
|
||||
return value
|
||||
|
||||
@@ -11,7 +11,6 @@ import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { Bom } from "@opencode-ai/util/bom"
|
||||
import { Effect, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { Environment } from "../../environment/index.js"
|
||||
import { FileMutation } from "../../file-mutation.js"
|
||||
import { Formatter } from "../../formatter.js"
|
||||
@@ -219,7 +218,7 @@ export const Plugin = {
|
||||
replacements,
|
||||
} satisfies Output
|
||||
}).pipe(
|
||||
fileMutation.withLock([path.resolve(location.directory, input.path)]),
|
||||
fileMutation.withLock([LocationMutation.resolvePath(location.directory, input.path)]),
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
content: `Edited ${output.files[0]?.file} (${output.replacements} replacement${output.replacements === 1 ? "" : "s"})`,
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { Effect, Result, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { Bom } from "@opencode-ai/util/bom"
|
||||
import { Environment } from "../../environment/index.js"
|
||||
import { Formatter } from "../../formatter.js"
|
||||
@@ -36,7 +35,7 @@ export const Output = Schema.Struct({
|
||||
})
|
||||
export type Output = typeof Output.Type
|
||||
|
||||
export const toModelOutput = (output: Output) =>
|
||||
export const toModelContent = (output: Output) =>
|
||||
[
|
||||
"Success. Updated the following files:",
|
||||
...output.applied.map(
|
||||
@@ -87,8 +86,10 @@ export const Plugin = {
|
||||
const parsed = Patch.parse(input.patchText)
|
||||
const lockTargets = Result.isSuccess(parsed)
|
||||
? parsed.success.flatMap((hunk) => [
|
||||
path.resolve(location.directory, hunk.path),
|
||||
...(hunk.type === "update" && hunk.movePath ? [path.resolve(location.directory, hunk.movePath)] : []),
|
||||
LocationMutation.resolvePath(location.directory, hunk.path),
|
||||
...(hunk.type === "update" && hunk.movePath
|
||||
? [LocationMutation.resolvePath(location.directory, hunk.movePath)]
|
||||
: []),
|
||||
])
|
||||
: []
|
||||
const fail = (operation: string, error: unknown) => {
|
||||
@@ -215,17 +216,6 @@ export const Plugin = {
|
||||
prepared,
|
||||
(change) =>
|
||||
Effect.gen(function* () {
|
||||
if (change.type === "add") {
|
||||
yield* environment.files
|
||||
.write(change.target.absolute, new TextEncoder().encode(change.content))
|
||||
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
|
||||
applied.push({
|
||||
type: change.type,
|
||||
resource: change.target.resource,
|
||||
target: change.target.absolute,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (change.type === "delete") {
|
||||
yield* environment.files
|
||||
.remove(change.target.absolute)
|
||||
@@ -237,7 +227,7 @@ export const Plugin = {
|
||||
})
|
||||
return
|
||||
}
|
||||
if (change.moveTarget) {
|
||||
if (change.type === "update" && change.moveTarget) {
|
||||
const moveTarget = change.moveTarget
|
||||
yield* environment.files
|
||||
.write(moveTarget.absolute, new TextEncoder().encode(change.content))
|
||||
@@ -296,7 +286,7 @@ export const Plugin = {
|
||||
fileMutation.withLock(lockTargets),
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
content: toModelOutput(output),
|
||||
content: toModelContent(output),
|
||||
metadata: { files: output.files },
|
||||
})),
|
||||
Effect.mapError((error) =>
|
||||
|
||||
@@ -35,7 +35,7 @@ export class CancelledError extends Schema.TaggedError<CancelledError>()("Questi
|
||||
}
|
||||
}
|
||||
|
||||
export const toModelOutput = (questions: ReadonlyArray<Question.Prompt>, answers: ReadonlyArray<Question.Answer>) => {
|
||||
export const toModelContent = (questions: ReadonlyArray<Question.Prompt>, answers: ReadonlyArray<Question.Answer>) => {
|
||||
const formatted = questions
|
||||
.map(
|
||||
(question, index) =>
|
||||
@@ -101,7 +101,7 @@ export const Plugin = {
|
||||
}
|
||||
return Effect.succeed({
|
||||
output,
|
||||
content: toModelOutput(input.questions, output.answers),
|
||||
content: toModelContent(input.questions, output.answers),
|
||||
metadata: { answers: output.answers },
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -15,7 +15,6 @@ import { Environment } from "../../environment/index.js"
|
||||
|
||||
export const name = "read"
|
||||
const FILENAME = "AGENTS.md"
|
||||
const SUPPORTED_MEDIA_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp", "application/pdf"])
|
||||
const LocationInput = Schema.Struct({
|
||||
path: Schema.String.annotate({ description: "File or directory to read" }),
|
||||
offset: ReadToolFileSystem.PageInput.fields.offset.annotate({
|
||||
@@ -104,7 +103,11 @@ export const Plugin = {
|
||||
Effect.catch(() => Effect.void),
|
||||
Effect.catchDefect(() => Effect.void),
|
||||
)
|
||||
if (content.type === "file" && content.encoding === "base64" && !SUPPORTED_MEDIA_MIMES.has(content.mime))
|
||||
if (
|
||||
content.type === "file" &&
|
||||
content.encoding === "base64" &&
|
||||
!ReadToolFileSystem.MEDIA_MIMES.has(content.mime)
|
||||
)
|
||||
return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource }))
|
||||
return content
|
||||
}).pipe(
|
||||
|
||||
@@ -3,7 +3,7 @@ export * as WebFetchTool from "./webfetch.js"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Duration, Effect, Schema } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { HttpClient, type HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { Parser } from "htmlparser2"
|
||||
import { Permission } from "../../permission.js"
|
||||
import { convertHTMLToMarkdown, MAX_MARKDOWN_BYTES } from "../html-markdown.js"
|
||||
@@ -58,18 +58,9 @@ const headers = (format: Format, userAgent: string) => ({
|
||||
const openCodeUserAgent =
|
||||
"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; OpenCode-User/1.0; +https://opencode.ai"
|
||||
|
||||
const isCloudflareChallenge = (error: unknown) => {
|
||||
if (!error || typeof error !== "object" || !("reason" in error)) return false
|
||||
const reason = error.reason
|
||||
if (
|
||||
!reason ||
|
||||
typeof reason !== "object" ||
|
||||
!("_tag" in reason) ||
|
||||
reason._tag !== "StatusCodeError" ||
|
||||
!("response" in reason)
|
||||
)
|
||||
return false
|
||||
const response = reason.response as HttpClientResponse.HttpClientResponse
|
||||
const isCloudflareChallenge = (error: HttpClientError.HttpClientError) => {
|
||||
if (error.reason._tag !== "StatusCodeError") return false
|
||||
const response = error.reason.response
|
||||
return response.status === 403 && response.headers["cf-mitigated"] === "challenge"
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ export const Output = Schema.Struct({
|
||||
})
|
||||
export type Output = typeof Output.Type
|
||||
|
||||
export const toModelOutput = (output: Output) =>
|
||||
export const toModelContent = (output: Output) =>
|
||||
`${output.existed ? "Wrote" : "Created"} file successfully: ${output.resource}`
|
||||
|
||||
/** Deferred write UX integrations remain visible at the model-facing seam. */
|
||||
@@ -98,7 +98,7 @@ export const Plugin = {
|
||||
}
|
||||
return result
|
||||
}).pipe(
|
||||
Effect.map((output) => ({ output, content: toModelOutput(output) })),
|
||||
Effect.map((output) => ({ output, content: toModelContent(output) })),
|
||||
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
|
||||
),
|
||||
}),
|
||||
|
||||
@@ -18,7 +18,7 @@ const FIRST_CHUNK = 256 * 1024
|
||||
const MAX_LINE_LENGTH = 2_000
|
||||
const TREE_BASE = 6
|
||||
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
|
||||
const MEDIA_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf"])
|
||||
export const MEDIA_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf"])
|
||||
|
||||
export class BinaryFileError extends Schema.TaggedError<BinaryFileError>()("ReadTool.BinaryFileError", {
|
||||
resource: Schema.String,
|
||||
@@ -361,12 +361,8 @@ const textOffset = (tree: TextNode, newline: number) => {
|
||||
if (!child) return tree.summary.bytes
|
||||
node = child
|
||||
}
|
||||
for (const [index, byte] of node.bytes.entries()) {
|
||||
if (byte !== 10) continue
|
||||
remaining--
|
||||
if (remaining === 0) return offset + index + 1
|
||||
}
|
||||
return tree.summary.bytes
|
||||
const end = nthNewline(node.bytes, remaining)
|
||||
return end === undefined ? tree.summary.bytes : offset + end
|
||||
}
|
||||
|
||||
const nthNewline = (bytes: Uint8Array, count: number) => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user