Compare commits

..
Author SHA1 Message Date
rekram1-node 7dc227b35a feat(session): allow metadata updates 2026-09-19 18:05:37 +00:00
423 changed files with 2949 additions and 8719 deletions
+2 -3
View File
@@ -112,12 +112,11 @@ jobs:
- name: Run unit tests
timeout-minutes: 20
run: |
# The runners have four vCPUs, and each Bun test process performs its own concurrent work.
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
GITHUB_ACTIONS=false bun turbo test --concurrency=3
GITHUB_ACTIONS=false bun turbo test
exit 0
fi
GITHUB_ACTIONS=false bun turbo test --affected --concurrency=3
GITHUB_ACTIONS=false bun turbo test --affected
env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && format('{0}^1', github.sha) || github.event.before }}
+39 -39
View File
@@ -32,7 +32,7 @@
},
"packages/ai": {
"name": "@opencode/ai",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode/schema": "workspace:*",
@@ -54,7 +54,7 @@
},
"packages/app": {
"name": "@opencode/app",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -111,7 +111,7 @@
},
"packages/cli": {
"name": "@opencode/cli",
"version": "2.0.12",
"version": "2.0.10",
"bin": {
"opencode2": "./bin/opencode2.cjs",
},
@@ -175,7 +175,7 @@
},
"packages/client": {
"name": "@opencode/client",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -201,7 +201,7 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -214,7 +214,7 @@
},
"packages/console/app": {
"name": "@opencode/console-app",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -250,7 +250,7 @@
},
"packages/console/core": {
"name": "@opencode/console-core",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -277,7 +277,7 @@
},
"packages/console/function": {
"name": "@opencode/console-function",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode/console-core": "workspace:*",
@@ -294,7 +294,7 @@
},
"packages/console/mail": {
"name": "@opencode/console-mail",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -318,7 +318,7 @@
},
"packages/console/support": {
"name": "@opencode/console-support",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode/console-core": "workspace:*",
@@ -338,7 +338,7 @@
},
"packages/core": {
"name": "@opencode/core",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/gateway": "3.0.104",
@@ -406,10 +406,10 @@
},
"packages/desktop": {
"name": "@opencode/desktop",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"electron-context-menu": "5.0.0",
"electron-context-menu": "4.1.2",
"electron-log": "^5",
"electron-updater": "6.8.9",
"lighthouse": "13.4.1",
@@ -436,7 +436,7 @@
"drizzle-kit": "catalog:",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"electron": "44.4.3",
"electron": "42.10.1",
"electron-builder": "26.15.7",
"electron-vite": "6.0.0-beta.1",
"puppeteer-core": "25.9.0",
@@ -455,7 +455,7 @@
},
"packages/enterprise": {
"name": "@opencode/enterprise",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/sdk": "1.18.21",
@@ -492,7 +492,7 @@
},
"packages/function": {
"name": "@opencode/function",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -508,7 +508,7 @@
},
"packages/http-recorder": {
"name": "@opencode/http-recorder",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.112",
},
@@ -527,7 +527,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode/httpapi-codegen",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -540,7 +540,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -554,7 +554,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -569,7 +569,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode/ai": "workspace:*",
@@ -608,7 +608,7 @@
},
"packages/plugin-browser": {
"name": "@opencode/plugin-browser",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -638,7 +638,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -653,7 +653,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -677,7 +677,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -698,7 +698,7 @@
},
"packages/server": {
"name": "@opencode/server",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
@@ -720,7 +720,7 @@
},
"packages/session-ui": {
"name": "@opencode/session-ui",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
@@ -755,7 +755,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/core": "workspace:*",
@@ -775,7 +775,7 @@
},
"packages/stats/app": {
"name": "@opencode/stats-app",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -809,7 +809,7 @@
},
"packages/stats/core": {
"name": "@opencode/stats-core",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -828,7 +828,7 @@
},
"packages/stats/server": {
"name": "@opencode/stats-server",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -874,7 +874,7 @@
},
"packages/theme": {
"name": "@opencode/theme",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -888,7 +888,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -923,7 +923,7 @@
},
"packages/ui": {
"name": "@opencode/ui",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -958,7 +958,7 @@
},
"packages/util": {
"name": "@opencode/util",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -991,7 +991,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1032,7 +1032,7 @@
},
"services/update": {
"name": "@opencode/update",
"version": "2.0.12",
"version": "2.0.10",
"dependencies": {
"jose": "6.0.11",
"semver": "catalog:",
@@ -3880,13 +3880,13 @@
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
"electron": ["electron@44.4.3", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-LTpSFTB40qVCXIX5xMo+cgHI/Jjkbjw7VpB26PccEbroqOn72LBukeaDwPVo1fBYzSzs0c9iPuAucFCO7Tw81Q=="],
"electron": ["electron@42.10.1", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-ITc1HPeoDzsxCCaH6MFsN67Nq2nUiJf5N9pBWxzhUpFfKJF0IwiAkKT3emg1lPbvC4OfFE+Cdwe4vhgdOZ1YKg=="],
"electron-builder": ["electron-builder@26.15.7", "", { "dependencies": { "app-builder-lib": "26.15.7", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.15.7", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "./cli.js", "install-app-deps": "./install-app-deps.js" } }, "sha512-DBpaNzxsPs1BvEblzFoNriSbzsBqDCy/gseIngeEhYzQG1IxfB7Hvc2tBBVmpWE2BTQGP9J1RrAvDT+Vc/uAxg=="],
"electron-builder-squirrel-windows": ["electron-builder-squirrel-windows@26.15.7", "", { "dependencies": { "app-builder-lib": "26.15.7", "builder-util": "26.15.3", "electron-winstaller": "5.4.0" } }, "sha512-B4uvn2NzFSuf084udWqugludFull6CRJiWe2dLzMnZLl6G5hdAGk0fsBMGlBSpKjvQCJn8IPc+S7OnJ+GXqwLA=="],
"electron-context-menu": ["electron-context-menu@5.0.0", "", { "dependencies": { "cli-truncate": "^4.0.0", "electron-dl": "^4.0.0", "electron-is-dev": "^3.0.1" } }, "sha512-rgFpRtwY0/rhsRCoz9rE6VM4WueEsLbIpca7ucOhERVrbGB2dQrxa9xwBNLplU54jRgPuv6nTEbxoplE5bzy2A=="],
"electron-context-menu": ["electron-context-menu@4.1.2", "", { "dependencies": { "cli-truncate": "^4.0.0", "electron-dl": "^4.0.0", "electron-is-dev": "^3.0.1" } }, "sha512-9xYTUV0oRqKL50N9W71IrXNdVRB0LuBp3R1zkUdUc2wfIa2/QZwYYj5RLuO7Tn7ZSLVIaO3X6u+EIBK+cBvzrQ=="],
"electron-dl": ["electron-dl@4.0.0", "", { "dependencies": { "ext-name": "^5.0.0", "pupa": "^3.1.0", "unused-filename": "^4.0.1" } }, "sha512-USiB9816d2JzKv0LiSbreRfTg5lDk3lWh0vlx/gugCO92ZIJkHVH0UM18EHvKeadErP6Xn4yiTphWzYfbA2Ong=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-/jah4P2a0aGJNJ0aMdlFEbGHAXx33lqxHbc7UmpLFDg=",
"aarch64-linux": "sha256-L3SoZ24qNXicsE2FK6LATQjOmjPxY679RjugrUyO/1Y=",
"aarch64-darwin": "sha256-pI9NT8KWUPi+JCk6DYMqIAYmBqTbi13uL4VdNV3WS6Y=",
"x86_64-darwin": "sha256-rMAGhTTz46KA5Ya7E5J0af7Bn1QzTDhTaNfNJm8qfsw="
"x86_64-linux": "sha256-8mOzCscBAuogG4tm8CroqjTX5E5yzCvTobbhKvxQP0U=",
"aarch64-linux": "sha256-drrRSpzxC8bfaTXBpOyaN0QAyBVV7WJzm0NstJ+8sAE=",
"aarch64-darwin": "sha256-YY5A/zxLPONvgnI+DZlzcD2K5Q9PIw4F2YbDxKbT4UU=",
"x86_64-darwin": "sha256-/c/Ew4onA+9+l6GRKXz3zWq3QFESM7j5RyGLCWRPQFw="
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode",
"description": "AI-powered development tool",
"version": "2.0.12",
"version": "2.0.10",
"private": true,
"type": "module",
"packageManager": "bun@1.4.2",
-41
View File
@@ -29,47 +29,6 @@ await Effect.runPromise(program.pipe(Effect.provide(llmLayer)))
Run `LLMClient.stream(request)` instead of `generate` when you want incremental `LLMEvent`s. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses, Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployment.
## Experimental evaluation
Evaluation models compare shared state with typed choice, score, and boolean questions. The API is
isolated under an experimental entrypoint and provider namespace while the contract evolves:
```ts
import { Effect } from "effect"
import { Evaluation, EvaluationClient } from "@opencode/ai/experimental"
import { TypeSafeAI } from "@opencode/ai/providers"
const model = TypeSafeAI.configure().experimental.evaluation("jev-latest")
const program = Evaluation.evaluate({
model,
state: "I was charged twice. Please refund the duplicate payment.",
questions: {
department: {
type: "choice",
instructions: "Which team should handle this?",
criteria: { billing: "Payments and refunds", technical: "Bugs and outages" },
},
urgency: {
type: "score",
instructions: "How urgent is this?",
criteria: ["Can wait", "Needs prompt attention", "Blocking revenue"],
},
refund: { type: "boolean", instructions: "Is the customer asking for a refund?" },
},
})
const response = await Effect.runPromise(program.pipe(Effect.provide(EvaluationClient.fetchLayer)))
console.log(response.answers.department.choice)
console.log(response.answers.refund.probability)
```
`TypeSafeAI` reads `TYPESAFE_API_KEY`. `OpenCodeZen` exposes the same selector and reads
`OPENCODE_API_KEY`. The common API uses `boolean`; System One routes lower it to native `noul`.
Choice and score confidence plus score legends remain available in provider metadata, and the
provider's rounded probabilities are returned unchanged.
## Alibaba Cloud Model Studio
`Alibaba` provides standard Model Studio inference. Configure a region explicitly, then select
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.12",
"version": "2.0.10",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
-29
View File
@@ -1,29 +0,0 @@
export { EvaluationClient } from "./experimental/evaluation-client.js"
export {
BooleanAnswer,
BooleanQuestion,
ChoiceAnswer,
ChoiceQuestion,
Evaluation,
EvaluationAnswer,
EvaluationInput,
EvaluationModel,
EvaluationModelSchema,
EvaluationQuestion,
EvaluationRequest,
EvaluationResponse,
EvaluationRounding,
ScoreAnswer,
ScoreQuestion,
} from "./experimental/evaluation.js"
export type {
AnswerFor,
AnswersFor,
EvaluationModelOptions,
EvaluationOptions,
EvaluationQuestions,
EvaluationRequestFor,
EvaluationRequestInput,
EvaluationResponseFor,
EvaluationRoute,
} from "./experimental/evaluation.js"
@@ -1,54 +0,0 @@
import { Context, Effect, Layer } from "effect"
import { RequestExecutor } from "../route/executor.js"
import { mergeHttpOptions, type AIError } from "../schema/index.js"
import { sanitizeSurrogates } from "../utils/sanitize.js"
import {
type EvaluationOptions,
type EvaluationQuestions,
type EvaluationRequestFor,
type EvaluationResponseFor,
} from "./evaluation.js"
export type Execute = RequestExecutor.Interface["execute"]
export interface Interface {
readonly evaluate: <Options extends EvaluationOptions, const Questions extends EvaluationQuestions>(
request: EvaluationRequestFor<Options, Questions>,
) => Effect.Effect<EvaluationResponseFor<Questions>, AIError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/AI/Experimental/EvaluationClient") {}
export const evaluate = <Options extends EvaluationOptions, const Questions extends EvaluationQuestions>(
request: EvaluationRequestFor<Options, Questions>,
): Effect.Effect<EvaluationResponseFor<Questions>, AIError, Service> =>
Effect.flatMap(Service, (client) => client.evaluate(request))
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
return Service.of({
evaluate: (request) =>
request.model.route.evaluate(
{
...sanitizeSurrogates({
...request,
model: undefined,
http: mergeHttpOptions(request.model.http, request.http),
}),
model: request.model,
},
executor.execute,
),
})
}),
)
export const fetchLayer = layer.pipe(Layer.provide(RequestExecutor.fetchLayer))
export const EvaluationClient = {
Service,
layer,
fetchLayer,
evaluate,
} as const
-245
View File
@@ -1,245 +0,0 @@
import { Effect, Schema } from "effect"
import {
AIError,
HttpOptions,
InvalidRequestError,
ModelID,
ProviderID,
ProviderMetadata,
Usage,
} from "../schema/index.js"
import { EvaluationClient, Service, type Execute } from "./evaluation-client.js"
export const EvaluationInput = Schema.Union([Schema.String, Schema.JsonObject, Schema.Array(Schema.Json)])
export type EvaluationInput = Schema.Schema.Type<typeof EvaluationInput>
const EvaluationCriterion = Schema.NullOr(EvaluationInput)
const ChoiceCriteria = Schema.Record(Schema.String, EvaluationCriterion).pipe(
Schema.refine((x): x is typeof x => Object.keys(x).length > 0, {
message: "Choice criteria must be a nonempty option map",
}),
)
export const ChoiceQuestion = Schema.Struct({
type: Schema.Literal("choice"),
instructions: EvaluationInput,
criteria: ChoiceCriteria,
})
export type ChoiceQuestion = Schema.Schema.Type<typeof ChoiceQuestion>
export const ScoreQuestion = Schema.Struct({
type: Schema.Literal("score"),
instructions: EvaluationInput,
criteria: Schema.Array(EvaluationCriterion).check(Schema.isMinLength(2)),
})
export type ScoreQuestion = Schema.Schema.Type<typeof ScoreQuestion>
export const BooleanQuestion = Schema.Struct({
type: Schema.Literal("boolean"),
instructions: EvaluationInput,
criteria: Schema.optional(
Schema.Struct({
true: Schema.optional(EvaluationCriterion),
false: Schema.optional(EvaluationCriterion),
}),
),
})
export type BooleanQuestion = Schema.Schema.Type<typeof BooleanQuestion>
export const EvaluationQuestion = Schema.Union([ChoiceQuestion, ScoreQuestion, BooleanQuestion]).pipe(
Schema.toTaggedUnion("type"),
)
export type EvaluationQuestion = Schema.Schema.Type<typeof EvaluationQuestion>
export type EvaluationQuestions = Readonly<Record<string, EvaluationQuestion>>
const EvaluationQuestions = Schema.Record(Schema.String, EvaluationQuestion).pipe(
Schema.refine((x): x is typeof x => Object.keys(x).length > 0, {
message: "Evaluation questions must be a nonempty map",
}),
)
const Probability = Schema.Number.check(Schema.isBetween({ minimum: 0, maximum: 1 }))
export const ChoiceAnswer = Schema.Struct({
type: Schema.Literal("choice"),
choice: Schema.String,
probabilities: Schema.optional(Schema.Record(Schema.String, Probability)),
})
export type ChoiceAnswer = Schema.Schema.Type<typeof ChoiceAnswer>
export const ScoreAnswer = Schema.Struct({
type: Schema.Literal("score"),
score: Schema.Number,
probabilities: Schema.optional(Schema.Record(Schema.String, Probability)),
})
export type ScoreAnswer = Schema.Schema.Type<typeof ScoreAnswer>
export const BooleanAnswer = Schema.Struct({
type: Schema.Literal("boolean"),
probability: Probability,
})
export type BooleanAnswer = Schema.Schema.Type<typeof BooleanAnswer>
export const EvaluationAnswer = Schema.Union([ChoiceAnswer, ScoreAnswer, BooleanAnswer]).pipe(
Schema.toTaggedUnion("type"),
)
export type EvaluationAnswer = Schema.Schema.Type<typeof EvaluationAnswer>
export type AnswerFor<Question extends EvaluationQuestion> = Question extends {
readonly type: "choice"
readonly criteria: infer Criteria
}
? {
readonly type: "choice"
readonly choice: Extract<keyof Criteria, string>
readonly probabilities?: Readonly<Record<Extract<keyof Criteria, string>, number>>
}
: Question extends { readonly type: "score" }
? ScoreAnswer
: BooleanAnswer
export type AnswersFor<Questions extends EvaluationQuestions> = {
readonly [ID in keyof Questions]: AnswerFor<Questions[ID]>
}
export type EvaluationOptions = Record<string, unknown>
export interface EvaluationRoute<Options extends EvaluationOptions = EvaluationOptions> {
readonly id: string
readonly evaluate: <const Questions extends EvaluationQuestions>(
request: EvaluationRequestFor<Options, Questions>,
execute: Execute,
) => Effect.Effect<EvaluationResponseFor<Questions>, AIError>
}
export class EvaluationModel<Options extends EvaluationOptions = EvaluationOptions> {
declare protected readonly _Options: (options: Options) => Options
readonly id: ModelID
readonly provider: ProviderID
readonly route: EvaluationRoute<Options>
readonly http?: HttpOptions
constructor(input: EvaluationModel.Input<Options>) {
this.id = input.id
this.provider = input.provider
this.route = input.route
this.http = input.http
}
static make<Options extends EvaluationOptions = EvaluationOptions>(input: EvaluationModel.MakeInput<Options>) {
return new EvaluationModel<Options>({
id: ModelID.make(input.id),
provider: ProviderID.make(input.provider),
route: input.route,
http: input.http,
})
}
}
export namespace EvaluationModel {
export interface Input<Options extends EvaluationOptions = EvaluationOptions> {
readonly id: ModelID
readonly provider: ProviderID
readonly route: EvaluationRoute<Options>
readonly http?: HttpOptions
}
export interface MakeInput<Options extends EvaluationOptions = EvaluationOptions>
extends Omit<Input<Options>, "id" | "provider"> {
readonly id: string | ModelID
readonly provider: string | ProviderID
}
}
export const EvaluationModelSchema = Schema.declare(
(value): value is EvaluationModel => value instanceof EvaluationModel,
{
expected: "Evaluation.Model",
},
)
export class EvaluationRequest extends Schema.Class<EvaluationRequest>("Evaluation.Request")({
model: EvaluationModelSchema,
state: EvaluationInput,
questions: EvaluationQuestions,
options: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
http: Schema.optional(HttpOptions),
}) {
declare protected readonly _EvaluationRequest: void
}
export type EvaluationModelOptions<Model> = Model extends EvaluationModel<infer Options> ? Options : never
export type EvaluationRequestFor<
Options extends EvaluationOptions = EvaluationOptions,
Questions extends EvaluationQuestions = EvaluationQuestions,
> = Omit<EvaluationRequest, "model" | "questions" | "options"> & {
readonly model: EvaluationModel<Options>
readonly questions: Questions
readonly options?: Options
}
export type EvaluationRequestInput<
Model extends object = EvaluationModel,
Questions extends EvaluationQuestions = EvaluationQuestions,
> = Omit<ConstructorParameters<typeof EvaluationRequest>[0], "model" | "questions" | "options" | "http"> & {
readonly model: Model
readonly questions: Questions
readonly options?: NoInfer<EvaluationModelOptions<Model>>
readonly http?: HttpOptions.Input
} & (Model extends EvaluationModel<EvaluationModelOptions<Model>> ? unknown : never)
export class EvaluationRounding extends Schema.Class<EvaluationRounding>("Evaluation.Rounding")({
probabilityDecimals: Schema.optional(Schema.Int),
scoreDecimals: Schema.optional(Schema.Int),
}) {}
export class EvaluationResponse extends Schema.Class<EvaluationResponse>("Evaluation.Response")({
model: ModelID,
answers: Schema.Record(Schema.String, EvaluationAnswer),
usage: Schema.optional(Usage),
rounding: Schema.optional(EvaluationRounding),
providerMetadata: Schema.optional(ProviderMetadata),
}) {}
export type EvaluationResponseFor<Questions extends EvaluationQuestions> = Omit<EvaluationResponse, "answers"> & {
readonly answers: AnswersFor<Questions>
}
export function request<const Model extends object, const Questions extends EvaluationQuestions>(
input: EvaluationRequestInput<Model, Questions>,
): EvaluationRequestFor<EvaluationModelOptions<Model>, Questions>
export function request(input: EvaluationRequest): EvaluationRequest
export function request(input: EvaluationRequest | EvaluationRequestInput) {
if (input instanceof EvaluationRequest) return input
return new EvaluationRequest({
...input,
model: input.model as unknown as EvaluationModel,
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
})
}
export function evaluate<const Model extends object, const Questions extends EvaluationQuestions>(
input: EvaluationRequestInput<Model, Questions>,
): Effect.Effect<EvaluationResponseFor<Questions>, AIError, Service>
export function evaluate(input: EvaluationRequest): Effect.Effect<EvaluationResponse, AIError, Service>
export function evaluate(input: EvaluationRequest | EvaluationRequestInput) {
return Effect.try({
try: () => (input instanceof EvaluationRequest ? input : request(input)),
catch: (cause) =>
new AIError({
reason: new InvalidRequestError({
message: cause instanceof Error ? cause.message : String(cause),
cause,
}),
}),
}).pipe(
Effect.flatMap((request) =>
EvaluationClient.evaluate(request as EvaluationRequestFor<EvaluationOptions, EvaluationQuestions>),
),
)
}
export const Evaluation = {
request,
evaluate,
} as const
-222
View File
@@ -1,222 +0,0 @@
import { Effect, Schema } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import {
BooleanAnswer,
ChoiceAnswer,
ChoiceQuestion,
EvaluationInput,
EvaluationModel,
EvaluationResponse,
EvaluationRounding,
ScoreAnswer,
ScoreQuestion,
type EvaluationResponseFor,
type EvaluationRoute,
} from "./evaluation.js"
import { Auth, type Definition as AuthDefinition } from "../route/auth.js"
import {
AIError,
HttpContext,
HttpOptions,
InvalidProviderOutputError,
InvalidRequestError,
ModelID,
Usage,
mergeJsonRecords,
} from "../schema/index.js"
const Noul = Schema.Struct({
type: Schema.Literal("noul"),
instructions: EvaluationInput,
criteria: Schema.optional(
Schema.Struct({
true: Schema.optional(Schema.NullOr(EvaluationInput)),
false: Schema.optional(Schema.NullOr(EvaluationInput)),
}),
),
})
const Question = Schema.Union([
ChoiceQuestion.pipe(
Schema.refine((x): x is typeof x => Object.keys(x.criteria).length <= 255, {
message: "System One Choice questions support at most 255 options",
}),
),
ScoreQuestion.pipe(
Schema.refine((x): x is typeof x => x.criteria.length <= 10, {
message: "System One Score questions support at most 10 levels",
}),
),
Noul,
])
const Request = Schema.StructWithRest(
Schema.Struct({
model: Schema.String,
state: EvaluationInput,
questions: Schema.Record(Schema.String, Question),
}),
[Schema.Record(Schema.String, Schema.Any)],
)
const Probability = Schema.Number.check(Schema.isBetween({ minimum: 0, maximum: 1 }))
const NoulAnswer = Schema.Struct({ type: Schema.Literal("noul"), noul: Probability })
const Choice = Schema.Struct({
type: Schema.Literal("choice"),
choice: Schema.String,
probabilities: Schema.Record(Schema.String, Probability),
confidence: Schema.optional(Probability),
})
const Score = Schema.Struct({
type: Schema.Literal("score"),
score: Schema.Number,
probabilities: Schema.Record(Schema.String, Probability),
legend: Schema.optional(Schema.Record(Schema.String, Schema.Json)),
confidence: Schema.optional(Probability),
})
const NativeUsage = Schema.Struct({
input_tokens: Schema.optional(Schema.Number),
output_tokens: Schema.optional(Schema.Number),
})
const encode = Schema.encodeUnknownEffect(Schema.fromJsonString(Request))
const exact = (x: Readonly<Record<string, unknown>>, keys: ReadonlyArray<string>) =>
Object.keys(x).length === keys.length && keys.every((key) => Object.hasOwn(x, key))
export interface ModelInput {
readonly id: string | ModelID
readonly provider: string
readonly providerMetadataKey: string
readonly auth: AuthDefinition
readonly baseURL: string
readonly headers?: Record<string, string>
readonly http?: HttpOptions
}
export const model = (cfg: ModelInput) => {
const route: EvaluationRoute = {
id: "system-one",
evaluate: (req, send) =>
Effect.gen(function* () {
const url = new URL(`${cfg.baseURL.replace(/\/$/, "")}/systemone`)
Object.entries(req.http?.query ?? {}).forEach(([key, value]) => url.searchParams.set(key, value))
const body = yield* encode({
...mergeJsonRecords(req.options, req.http?.body),
model: req.model.id,
state: req.state,
questions: Object.fromEntries(
Object.entries(req.questions).map(([id, x]) => [id, x.type === "boolean" ? { ...x, type: "noul" } : x]),
),
}).pipe(
Effect.mapError(
(cause) => new AIError({ reason: new InvalidRequestError({ message: cause.message, cause }) }),
),
)
const headers = yield* Auth.toEffect(cfg.auth)({
request: req,
method: "POST",
url: url.toString(),
body,
headers: Headers.fromInput({ ...cfg.headers, ...req.http?.headers }),
})
const res = yield* send(
HttpClientRequest.post(url).pipe(
HttpClientRequest.setHeaders(headers),
HttpClientRequest.bodyText(body, "application/json"),
),
)
const http = new HttpContext({ url: res.request.url, status: res.status, headers: res.headers })
const fail = (message: string, cause: unknown, body?: string) =>
new AIError({ reason: new InvalidProviderOutputError({ route: route.id, message, body, http, cause }) })
const text = yield* res.text.pipe(
Effect.mapError((cause) => fail("Failed to read the System One response", cause)),
)
const entries = Object.entries(req.questions)
const output = Schema.Struct({
model: Schema.String,
answers: Schema.Struct(
Object.fromEntries(
entries.map(([id, question]) => {
if (question.type === "boolean") return [id, NoulAnswer]
if (question.type === "choice") {
const keys = Object.keys(question.criteria)
return [
id,
Choice.pipe(
Schema.refine(
(x): x is typeof x =>
Object.hasOwn(question.criteria, x.choice) && exact(x.probabilities, keys),
{ message: `Question "${id}" returned an invalid choice answer` },
),
),
]
}
const keys = question.criteria.map((_, index) => String(index))
return [
id,
Score.pipe(
Schema.refine(
(x): x is typeof x =>
x.score >= 0 && x.score <= question.criteria.length - 1 && exact(x.probabilities, keys),
{ message: `Question "${id}" returned an invalid score answer` },
),
),
]
}),
) as Record<string, Schema.Codec<unknown>>,
),
usage: Schema.optional(NativeUsage),
})
const data = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(output))(text).pipe(
Effect.mapError((cause) => fail("System One returned an invalid response", cause, text)),
)
const confidence: Record<string, number> = {}
const legend: Record<string, Record<string, Schema.Json>> = {}
const answers = Object.fromEntries(
entries.map(([id, question]) => {
const answer = data.answers[id]
if (question.type === "boolean")
return [
id,
{ type: "boolean", probability: (answer as typeof NoulAnswer.Type).noul } satisfies BooleanAnswer,
]
if (question.type === "choice") {
const value = answer as typeof Choice.Type
if (value.confidence !== undefined) confidence[id] = value.confidence
return [
id,
{ type: "choice", choice: value.choice, probabilities: value.probabilities } satisfies ChoiceAnswer,
]
}
const value = answer as typeof Score.Type
if (value.confidence !== undefined) confidence[id] = value.confidence
if (value.legend !== undefined) legend[id] = value.legend
return [id, { type: "score", score: value.score, probabilities: value.probabilities } satisfies ScoreAnswer]
}),
)
const meta = {
...(Object.keys(confidence).length === 0 ? {} : { confidence }),
...(Object.keys(legend).length === 0 ? {} : { legend }),
}
return new EvaluationResponse({
model: ModelID.make(data.model),
answers,
usage: data.usage
? new Usage({
inputTokens: data.usage.input_tokens,
outputTokens: data.usage.output_tokens,
totalTokens:
data.usage.input_tokens === undefined && data.usage.output_tokens === undefined
? undefined
: (data.usage.input_tokens ?? 0) + (data.usage.output_tokens ?? 0),
providerMetadata: { [cfg.providerMetadataKey]: data.usage },
})
: undefined,
rounding: new EvaluationRounding({ probabilityDecimals: 2, scoreDecimals: 2 }),
providerMetadata: Object.keys(meta).length === 0 ? undefined : { [cfg.providerMetadataKey]: meta },
}) as EvaluationResponseFor<typeof req.questions>
}),
}
return EvaluationModel.make({ id: cfg.id, provider: cfg.provider, route, http: cfg.http })
}
export const SystemOne = { model } as const
@@ -73,6 +73,11 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
)
if (event.type === "error") {
terminal = true
yield* OpenResponses.decodeKnownErrorEvent(event).pipe(
Effect.mapError((cause) =>
ProviderShared.eventError(options.id, `${options.name} returned a malformed error event`, frame, cause),
),
)
return {
type: "provider-failure",
error: OpenResponses.providerFailure(event, `${options.name} stream error`, frame),
@@ -108,7 +108,7 @@ const incremental = (
return input.slice(baseline.length)
}
const code = (event: OpenResponses.Event) => OpenResponses.errorDetail(event).code
const code = (event: OpenResponses.Event) => event.code || event.error?.code || event.response?.error?.code || undefined
const rejected = (
observation: Extract<ChannelObservation, { readonly type: "provider-failure" }>,
+94 -28
View File
@@ -1,4 +1,4 @@
import { Effect, Option, Schema } from "effect"
import { Effect, Option, Schema, SchemaGetter } from "effect"
import type { Content } from "@opencode/schema/tool"
import { HttpTransport } from "../route/transport/index.js"
import { Protocol } from "../route/protocol.js"
@@ -333,13 +333,53 @@ export const StreamItem = Schema.StructWithRest(
export type StreamItem = Schema.Schema.Type<typeof StreamItem>
export type OutputItem = StreamItem & { readonly id: string }
// Responses-compatible providers put error details at the top level, under `error`, or under
// `response.error`, and gateways reshape them freely: strings, numeric codes, extra fields. Those
// fields decode as opaque values and `errorDetail` reads them defensively, so an error frame can
// only fail on invalid JSON and otherwise always classifies with the raw body as the fallback.
// Responses-compatible providers put streaming error details at the top level or
// under `error`, and response failures under `response.error`. Accept all three shapes.
// https://www.openresponses.org/specification
const asText = (value: unknown) =>
typeof value === "string" && value.length > 0 ? value : typeof value === "number" ? String(value) : undefined
const OpenResponsesErrorObject = Schema.Struct({
type: optionalNull(Schema.String),
code: optionalNull(Schema.String),
message: optionalNull(Schema.String),
param: optionalNull(Schema.String),
})
const OpenResponsesErrorPayload = Schema.Union([Schema.String, OpenResponsesErrorObject]).pipe(
Schema.decodeTo(OpenResponsesErrorObject, {
decode: SchemaGetter.transform((error) => (typeof error === "string" ? { message: error } : error)),
encode: SchemaGetter.passthrough(),
}),
)
type OpenResponsesErrorPayload = Schema.Schema.Type<typeof OpenResponsesErrorPayload>
const WebSocketErrorHeader = Schema.Union([Schema.String, Schema.Number, Schema.Boolean])
export const WebSocketErrorEvent = Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("error"),
status: Schema.optional(Schema.Number),
status_code: Schema.optional(Schema.Number),
code: optionalNull(Schema.String),
message: Schema.optional(Schema.String),
param: optionalNull(Schema.String),
error: optionalNull(OpenResponsesErrorPayload),
headers: Schema.optional(Schema.Record(Schema.String, WebSocketErrorHeader)),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
const decodeWebSocketErrorEvent = Schema.decodeUnknownEffect(WebSocketErrorEvent)
export const decodeKnownErrorEvent = (event: Event) =>
decodeWebSocketErrorEvent({
...event,
status: typeof event.status === "number" ? event.status : undefined,
status_code: typeof event.status_code === "number" ? event.status_code : undefined,
headers: ProviderShared.isRecord(event.headers)
? Object.fromEntries(
Object.entries(event.headers).filter(
(entry): entry is [string, string | number | boolean] =>
typeof entry[1] === "string" || typeof entry[1] === "number" || typeof entry[1] === "boolean",
),
)
: undefined,
})
export const Event = Schema.StructWithRest(
Schema.Struct({
@@ -360,18 +400,31 @@ export const Event = Schema.StructWithRest(
incomplete_details: optionalNull(Schema.Struct({ reason: Schema.optional(Schema.String) })),
output: Schema.optional(Schema.Array(StreamItem)),
usage: optionalNull(OpenResponsesUsage),
error: Schema.optional(Schema.Unknown),
error: optionalNull(OpenResponsesErrorPayload),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
),
),
code: Schema.optional(Schema.Unknown),
message: Schema.optional(Schema.Unknown),
error: Schema.optional(Schema.Unknown),
code: optionalNull(Schema.String),
message: Schema.optional(Schema.String),
param: optionalNull(Schema.String),
error: optionalNull(OpenResponsesErrorPayload),
status: Schema.optional(Schema.Unknown),
status_code: Schema.optional(Schema.Unknown),
headers: Schema.optional(Schema.Unknown),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
).pipe(
Schema.decode({
decode: SchemaGetter.transform((event) => {
if (event.type !== "error" || event.error != null) return event
const { code, message, param, ...rest } = event
if (code === undefined && message === undefined && param === undefined) return event
// Flat errors (for example, Meta's) can also arrive through generic Responses endpoints.
return { ...rest, error: { code, message, param } }
}),
encode: SchemaGetter.passthrough(),
}),
)
export type Event = Schema.Schema.Type<typeof Event>
export type NormalizedEvent = Event & { readonly item?: OutputItem | null }
@@ -380,15 +433,16 @@ const decodeEventValue = Schema.decodeUnknownEffect(Event)
const decodeFrame = Schema.decodeUnknownEffect(ProviderShared.Json)
/**
* Decodes one WebSocket frame. Some providers and gateways answer a rejected `response.create` with a bare
* `{ "error": ... }` envelope and no event type; that reads as an error event so it classifies instead of
* failing decoding.
* Decodes one WebSocket frame. xAI answers a rejected `response.create` with `{ "error": { "message", "type" } }` and no
* event type; that envelope reads as an error event so the failure classifies instead of failing decoding.
*/
export const decodeChannelEvent = (frame: string) =>
decodeFrame(frame).pipe(
Effect.flatMap((value) =>
decodeEventValue(
ProviderShared.isRecord(value) && value.type === undefined && value.error != null
ProviderShared.isRecord(value) &&
value.type === undefined &&
(typeof value.error === "string" || ProviderShared.isRecord(value.error))
? { ...value, type: "error" }
: value,
),
@@ -1368,21 +1422,22 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
return [{ ...current, lifecycle }, events] satisfies StepResult
})
/** Error code and message from wherever the frame put them; top-level fields win over nested ones. */
export const errorDetail = (event: Event) => {
const raw = event.error ?? event.response?.error
const nested = typeof raw === "string" ? { message: raw } : ProviderShared.isRecord(raw) ? raw : undefined
return {
message: asText(event.message) ?? asText(nested?.message),
code: asText(event.code) ?? asText(nested?.code),
}
// Build the prettiest summary available from whatever the provider supplied.
// When both code and message are present, prefix the code so consumers see
// the failure mode (e.g. `rate_limit_exceeded: Slow down`) instead of just
// the bare message — production rate limits and context-length failures used
// to be indistinguishable from generic stream drops. Returns undefined when
// the payload carries no usable summary.
const providerErrorMessage = (event: Event, nested: OpenResponsesErrorPayload | undefined): string | undefined => {
const message = event.message || nested?.message || undefined
const code = event.code || nested?.code || undefined
if (message && code) return `${code}: ${message}`
return message || code
}
// Prefix the code when both are present (`rate_limit_exceeded: Slow down`) so the failure mode is
// visible; fall back to the raw frame rather than a generic message when neither decodes.
export const providerFailure = (event: Event, fallback: string, body = ProviderShared.encodeJson(event)) => {
const detail = errorDetail(event)
const summary = detail.message && detail.code ? `${detail.code}: ${detail.message}` : (detail.message ?? detail.code)
const nested = event.error ?? event.response?.error ?? undefined
const summary = providerErrorMessage(event, nested)
const message = summary ?? (body === "{}" ? fallback : body)
const status =
typeof event.status === "number"
@@ -1465,7 +1520,18 @@ export const step = (state: ParserState, event: NormalizedEvent) => {
if (event.type === "response.output_item.done") return onOutputItemDone(state, event.item)
if (event.type === "response.completed" || event.type === "response.incomplete") return onResponseFinish(state, event)
if (event.type === "response.failed") return providerFailure(event, `${state.name} response failed`)
if (event.type === "error") return providerFailure(event, `${state.name} stream error`)
if (event.type === "error")
return decodeKnownErrorEvent(event).pipe(
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])
}
+11 -1
View File
@@ -38,13 +38,23 @@ export type Settings = ProviderPackage.Settings &
const fromRequest = Effect.fn("GoogleVertex.fromRequest")(function* (request: LLMRequest) {
const { serviceTier: _, ...body } = yield* Gemini.protocol.body.from(request)
// Vertex's native REST schema rejects `id` on FunctionCall/FunctionResponse parts with HTTP 400,
// unlike AI Studio, so history minted there cannot be lowered verbatim.
const contents = body.contents.map((content) => ({
...content,
parts: (content.parts ?? []).map((part) => {
if ("functionCall" in part) return { ...part, functionCall: { ...part.functionCall, id: undefined } }
if ("functionResponse" in part) return { ...part, functionResponse: { ...part.functionResponse, id: undefined } }
return part
}),
}))
const value = request.providerOptions?.labels
const labels = ProviderShared.isRecord(value)
? Object.fromEntries(
Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
)
: undefined
return { ...body, labels }
return { ...body, contents, labels }
})
const protocol = {
-2
View File
@@ -24,10 +24,8 @@ export * as Moonshot from "./moonshot.js"
export * as OpenAI from "./openai.js"
export * as OpenAICompatible from "./openai-compatible.js"
export * as OpenAICompatibleResponses from "./openai-compatible-responses.js"
export * as OpenCodeZen from "./opencode-zen.js"
export * as OpenRouter from "./openrouter.js"
export * as TogetherAI from "./togetherai.js"
export * as TypeSafeAI from "./typesafe-ai.js"
export * as XAI from "./xai.js"
export * as ZAI from "./zai.js"
export * as ZAICodingPlan from "./zai-coding-plan.js"
-31
View File
@@ -1,31 +0,0 @@
import { HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { SystemOne } from "../experimental/system-one.js"
export const id = ProviderID.make("opencode")
const baseURL = "https://opencode.ai/zen/v1"
export type Options = ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly headers?: Record<string, string>
readonly http?: HttpOptions.Input
}
export const configure = (input: Options = {}) => {
const evaluation = (modelID: string | ModelID) =>
SystemOne.model({
id: modelID,
provider: id,
providerMetadataKey: "opencode",
auth: AuthOptions.bearer(input, "OPENCODE_API_KEY"),
baseURL: input.baseURL ?? baseURL,
headers: input.headers,
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
})
return { id, experimental: { evaluation }, configure }
}
export const provider = configure()
export const experimental = provider.experimental
export * as OpenCodeZen from "./opencode-zen.js"
-31
View File
@@ -1,31 +0,0 @@
import { HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { SystemOne } from "../experimental/system-one.js"
export const id = ProviderID.make("typesafe-ai")
const baseURL = "https://api.typesafe.ai/v1"
export type Options = ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly headers?: Record<string, string>
readonly http?: HttpOptions.Input
}
export const configure = (input: Options = {}) => {
const evaluation = (modelID: string | ModelID) =>
SystemOne.model({
id: modelID,
provider: id,
providerMetadataKey: "typesafe",
auth: AuthOptions.bearer(input, "TYPESAFE_API_KEY"),
baseURL: input.baseURL ?? baseURL,
headers: input.headers,
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
})
return { id, experimental: { evaluation }, configure }
}
export const provider = configure()
export const experimental = provider.experimental
export * as TypeSafeAI from "./typesafe-ai.js"
+14 -4
View File
@@ -215,9 +215,7 @@ export const fromWebSocket = (
): Effect.Effect<WebSocketConnection, AIError> =>
Effect.gen(function* () {
yield* waitOpen(ws, input)
// The socket pushes frames synchronously and cannot be paused, so the hand-off to the consumer
// fiber must absorb whole read buffers. Bun delivers over a thousand small frames in one tick.
const messages = yield* Queue.unbounded<string | Uint8Array, AIError | Cause.Done<void>>()
const messages = yield* Queue.bounded<string | Uint8Array, AIError | Cause.Done<void>>(128)
const oversized = (message: string | Uint8Array) =>
typeof message === "string" ? new Blob([message]).size > MAX_FRAME_BYTES : message.byteLength > MAX_FRAME_BYTES
@@ -240,7 +238,19 @@ export const fromWebSocket = (
}
const offer = (message: string | Uint8Array) => {
if (rejectOversized(message)) return
Queue.offerUnsafe(messages, message)
if (Queue.offerUnsafe(messages, message)) return
Queue.failCauseUnsafe(
messages,
Cause.fail(
transportError("WebSocket inbound queue overflow", {
body: typeof message === "string" ? message : new TextDecoder().decode(message),
url: input.url,
operation: "read",
code: "queue-overflow",
phase: "receive",
}),
),
)
}
const onMessage = (event: MessageEvent) => {
-173
View File
@@ -1,173 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { Evaluation, EvaluationClient } from "../src/experimental.js"
import { OpenCodeZen, TypeSafeAI } from "../src/providers.js"
import { it } from "./lib/effect.js"
import { dynamicResponse } from "./lib/http.js"
describe("experimental Evaluation", () => {
it.effect("evaluates typed questions through System One", () =>
Effect.gen(function* () {
const response = yield* Evaluation.evaluate({
model: TypeSafeAI.configure({
apiKey: "test",
baseURL: "https://typesafe.test/v1/",
headers: { "x-default": "yes" },
http: { body: { deployment: "test" }, query: { api: "v1" } },
}).experimental.evaluation("jev-latest"),
state: { ticket: "Please refund the duplicate charge." },
questions: {
department: {
type: "choice",
instructions: "Which team should handle this?",
criteria: { billing: "Payments and refunds", technical: "Bugs and outages" },
},
urgency: {
type: "score",
instructions: "How urgent is this?",
criteria: ["Can wait", "Needs attention", "Blocking"],
},
refund: { type: "boolean", instructions: "Is the customer asking for a refund?" },
},
options: { trace: { enabled: true } },
http: { body: { request_metadata: "value" }, headers: { "x-request": "yes" }, query: { trace: "1" } },
})
expect(response.model).toBe("jev-1.13.0")
expect(response.answers.department).toEqual({
type: "choice",
choice: "billing",
probabilities: { billing: 0.9, technical: 0.1 },
})
expect(response.answers.urgency).toEqual({
type: "score",
score: 1.2,
probabilities: { "0": 0, "1": 0.8, "2": 0.2 },
})
expect(response.answers.refund).toEqual({ type: "boolean", probability: 0.97 })
expect(response.usage?.totalTokens).toBe(36)
expect(response.providerMetadata).toEqual({
typesafe: {
confidence: { department: 0.8, urgency: 0.6 },
legend: { urgency: { "0": "Can wait", "1": "Needs attention", "2": "Blocking" } },
},
})
}).pipe(
Effect.provide(
EvaluationClient.layer.pipe(
Layer.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
expect(request.url).toBe("https://typesafe.test/v1/systemone?api=v1&trace=1")
expect(request.headers.get("authorization")).toBe("Bearer test")
expect(request.headers.get("x-default")).toBe("yes")
expect(request.headers.get("x-request")).toBe("yes")
expect(JSON.parse(input.text)).toEqual({
deployment: "test",
request_metadata: "value",
trace: { enabled: true },
model: "jev-latest",
state: { ticket: "Please refund the duplicate charge." },
questions: {
department: {
type: "choice",
instructions: "Which team should handle this?",
criteria: { billing: "Payments and refunds", technical: "Bugs and outages" },
},
urgency: {
type: "score",
instructions: "How urgent is this?",
criteria: ["Can wait", "Needs attention", "Blocking"],
},
refund: { type: "noul", instructions: "Is the customer asking for a refund?" },
},
})
return input.respond(
JSON.stringify({
model: "jev-1.13.0",
answers: {
department: {
type: "choice",
choice: "billing",
probabilities: { billing: 0.9, technical: 0.1 },
confidence: 0.8,
},
urgency: {
type: "score",
score: 1.2,
probabilities: { "0": 0, "1": 0.8, "2": 0.2 },
legend: { "0": "Can wait", "1": "Needs attention", "2": "Blocking" },
confidence: 0.6,
},
refund: { type: "noul", noul: 0.97 },
},
usage: { input_tokens: 30, output_tokens: 6 },
}),
{ headers: { "content-type": "application/json" } },
)
}),
),
),
),
),
),
)
it.effect("configures the OpenCode Zen System One endpoint", () =>
Evaluation.evaluate({
model: OpenCodeZen.configure({ apiKey: "zen-key", baseURL: "https://zen.test/v1" }).experimental.evaluation(
"jev-1.13",
),
state: "hello",
questions: { greeting: { type: "boolean", instructions: "Is this a greeting?" } },
}).pipe(
Effect.tap((response) =>
Effect.sync(() => {
expect(response.answers.greeting.probability).toBe(0.99)
expect(response.usage?.providerMetadata).toEqual({
opencode: { input_tokens: 10, output_tokens: 2 },
})
}),
),
Effect.provide(
EvaluationClient.layer.pipe(
Layer.provide(
dynamicResponse((input) => {
expect(input.request.url).toBe("https://zen.test/v1/systemone")
expect(input.request.headers.authorization).toBe("Bearer zen-key")
return Effect.succeed(
input.respond(
JSON.stringify({
model: "jev-1.13.0",
answers: { greeting: { type: "noul", noul: 0.99 } },
usage: { input_tokens: 10, output_tokens: 2 },
}),
{ headers: { "content-type": "application/json" } },
),
)
}),
),
),
),
),
)
it.effect("rejects malformed questions before network I/O", () =>
Effect.gen(function* () {
const error = yield* Evaluation.evaluate({
model: TypeSafeAI.experimental.evaluation("jev-latest"),
state: "hello",
questions: { score: { type: "score", instructions: "How much?", criteria: ["only"] } },
}).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
}).pipe(
Effect.provide(
EvaluationClient.layer.pipe(
Layer.provide(dynamicResponse(() => Effect.die("invalid evaluation reached the network"))),
),
),
),
)
})
-60
View File
@@ -1,60 +0,0 @@
import { Effect } from "effect"
import { Evaluation, EvaluationClient, EvaluationModel, type EvaluationRoute } from "../src/experimental.js"
import type { Service } from "../src/experimental/evaluation-client.js"
import { OpenCodeZen, TypeSafeAI } from "../src/providers.js"
type Requirements<T> = T extends Effect.Effect<infer _A, infer _E, infer R> ? R : never
type Success<T> = T extends Effect.Effect<infer A, infer _E, infer _R> ? A : never
type Equal<A, B> = [A, B] extends [B, A] ? true : false
type Assert<T extends true> = T
const model = TypeSafeAI.configure({ apiKey: "test" }).experimental.evaluation("jev-latest")
const request = Evaluation.request({
model,
state: { ticket: "refund" },
questions: {
topic: {
type: "choice",
instructions: "Which team?",
criteria: { billing: null, support: { includes: ["help"] } },
},
severity: { type: "score", instructions: "How severe?", criteria: ["Low", "High"] },
refund: { type: "boolean", instructions: "Refund?" },
},
})
const result = EvaluationClient.evaluate(request)
type Result = Success<typeof result>
type Choice = Assert<Equal<Result["answers"]["topic"]["choice"], "billing" | "support">>
type ClientRequirements = Assert<Equal<Requirements<typeof result>, Service>>
void (true satisfies Choice)
void (true satisfies ClientRequirements)
Effect.gen(function* () {
const response = yield* Evaluation.evaluate({
model: OpenCodeZen.experimental.evaluation("jev-1.13"),
state: ["hello"],
questions: { greeting: { type: "boolean", instructions: "Greeting?" } },
})
response.answers.greeting.probability satisfies number
// @ts-expect-error Boolean answers do not contain a selected choice.
response.answers.greeting.choice
// @ts-expect-error Unknown question IDs are not exposed.
response.answers.missing
})
declare const route: EvaluationRoute<{ readonly temperature?: number }>
const custom = EvaluationModel.make({ id: "custom", provider: "custom", route })
Evaluation.evaluate({
model: custom,
state: "hello",
questions: { ok: { type: "boolean", instructions: "OK?" } },
options: { temperature: 0.5 },
})
// @ts-expect-error Selected evaluation models retain their request option types.
Evaluation.evaluate({
model: custom,
state: "hello",
questions: { ok: { type: "boolean", instructions: "OK?" } },
options: { temperature: "high" },
})
-8
View File
@@ -8,11 +8,9 @@ import {
CloudflareWorkersAI,
DeepSeek,
Fireworks,
OpenCodeZen,
OpenAI,
OpenAICompatible,
OpenRouter,
TypeSafeAI,
XAI,
} from "@opencode/ai/providers"
import {
@@ -25,7 +23,6 @@ import {
} from "@opencode/ai/protocols"
import * as AnthropicMessages from "@opencode/ai/protocols/anthropic-messages"
import { TestLLM } from "@opencode/ai/testing"
import { Evaluation, EvaluationClient } from "@opencode/ai/experimental"
describe("public exports", () => {
test("root exposes app-facing runtime APIs", () => {
@@ -40,9 +37,6 @@ describe("public exports", () => {
expect(TestLLM.layer).toBeFunction()
expect(TestLLM.testLayer).toBeFunction()
expect(TestLLM.Test.of).toBeFunction()
expect(Evaluation.evaluate).toBeFunction()
expect(EvaluationClient.layer).toBeDefined()
expect(EvaluationClient.fetchLayer).toBeDefined()
})
test("route barrel exposes route-authoring APIs", () => {
@@ -72,8 +66,6 @@ describe("public exports", () => {
expect(CloudflareWorkersAI.configure).toBeFunction()
expect(CloudflareWorkersAI.configure({ accountId: "fixture", apiKey: "fixture" }).model).toBeFunction()
expect(OpenRouter.model).toBeFunction()
expect(TypeSafeAI.experimental.evaluation).toBeFunction()
expect(OpenCodeZen.experimental.evaluation).toBeFunction()
expect(XAI.model).toBeFunction()
expect(XAI.provider.responses).toBe(XAI.responses)
expect(XAI.provider.chat).toBe(XAI.chat)
@@ -1,13 +1,9 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:google-vertex",
"provider:google-vertex",
"protocol:gemini"
],
"tags": ["prefix:google-vertex", "provider:google-vertex", "protocol:gemini"],
"name": "google-vertex/calls-a-tool",
"recordedAt": "2026-09-22T03:46:29.725Z"
"recordedAt": "2026-08-23T17:21:51.036Z"
},
"interactions": [
{
@@ -25,7 +21,7 @@
"headers": {
"content-type": "text/event-stream"
},
"body": "data: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"functionCall\": {\"name\": \"lookup_weather\",\"args\": {\"city\": \"Paris\"},\"id\": \"call_28936\"},\"thoughtSignature\": \"AY89a1/MVeBxtwu9l/96clFobOrrd7Q5MTf5o8/A22fc++1EsFYVeCFx9WCGmJ+D3yW3FWlRXKtJECYfprSoxixCSOYBNiV9g8/IkejbAxF26k1vxTsHkoqts0O2s8GqqBSRAavJfKQ/taTRbPuIq+b+RKZ8SDJFoWDnFjXpdS5S164uJTjmgs4ALd8oB1oQRycMsTbAY51TmkcNUnhARpjKQfqiizz4KfSRFjx7xDnza0v4jj73m2GV86eLDhVT7trIT+Dl5DsJt6RBLOE2Amse54mcpIbsYaANlY1YDU6FKuUBqnuwpl0OfhMXwWuYMogv1tH/0rTXsqNwmHyt/b9ZZXqYCdFXpMGt8udmoZ0d\"}]}}],\"usageMetadata\": {\"trafficType\": \"ON_DEMAND\"},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-09-22T03:46:28.953809Z\",\"responseId\": \"FPqxatGbOsSorb8PtY2z8Q4\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"\"}]},\"finishReason\": \"STOP\"}],\"usageMetadata\": {\"promptTokenCount\": 39,\"candidatesTokenCount\": 16,\"totalTokenCount\": 93,\"trafficType\": \"ON_DEMAND\",\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 39}],\"candidatesTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 16}],\"thoughtsTokenCount\": 38},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-09-22T03:46:28.953809Z\",\"responseId\": \"FPqxatGbOsSorb8PtY2z8Q4\"}\r\n\r\n"
"body": "data: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"functionCall\": {\"name\": \"lookup_weather\",\"args\": {\"city\": \"Paris\"},\"id\": \"call_425130\"},\"thoughtSignature\": \"AY89a1+1fXnLgYhHMuN3Ak6LBhT6PcrYOW7iPav4LfsacvG/Z6l1yJ+AsU7vWhFj/JyPIbsJJQ+GjohM9sCIZ6nqUOIg3reo/7osmrCvFrVHedTHQcwiPzoz2Kp3gb+uWjFAXxk1EX4IRAKcu0ox1W/Z9PpuZvHkTerGO2a82e02N6MAF1YhhtbXFvSdqLRih2Os68rdOk5/Bcld7ol8qUgeyIZ3CtI3OJ5jwRcD8LjvK33A7ZFzH5Bxp/peUmXvqnu5iNhnGBxZaJy/vupCtxRZxjaS+ojG0/UhyrnRiKIpbzQ0FBkxePPn8GCX/LOe2y3GUc98co8lN8OOuCd9ZmEdx5AjHmQkPO9fAV9SxG6Bda6SDWVL8o/Uz3WSQYoUEfAdoajEWIBvcisoeCJjb7zgmRRZ9VQSPl3RXj5LFRvX8jn0YKV1CahYbc24jA==\"}]}}],\"usageMetadata\": {\"trafficType\": \"ON_DEMAND\"},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:50.308576Z\",\"responseId\": \"LiyLauDqErCErb8Pj8aWkAs\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"\"}]},\"finishReason\": \"STOP\"}],\"usageMetadata\": {\"promptTokenCount\": 39,\"candidatesTokenCount\": 16,\"totalTokenCount\": 102,\"trafficType\": \"ON_DEMAND\",\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 39}],\"candidatesTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 16}],\"thoughtsTokenCount\": 47},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:50.308576Z\",\"responseId\": \"LiyLauDqErCErb8Pj8aWkAs\"}\r\n\r\n"
}
}
]
@@ -1,13 +1,9 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:google-vertex",
"provider:google-vertex",
"protocol:gemini"
],
"tags": ["prefix:google-vertex", "provider:google-vertex", "protocol:gemini"],
"name": "google-vertex/continues-after-a-tool-result",
"recordedAt": "2026-09-22T03:46:30.691Z"
"recordedAt": "2026-08-23T17:21:51.853Z"
},
"interactions": [
{
@@ -18,14 +14,14 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"What is the weather in Paris?\"}]},{\"role\":\"model\",\"parts\":[{\"functionCall\":{\"id\":\"call_paris_1\",\"name\":\"lookup_weather\",\"args\":{\"city\":\"Paris\"}},\"thoughtSignature\":\"skip_thought_signature_validator\"}]},{\"role\":\"user\",\"parts\":[{\"functionResponse\":{\"id\":\"call_paris_1\",\"name\":\"lookup_weather\",\"response\":{\"name\":\"lookup_weather\",\"content\":\"18C, light rain\"}}}]}],\"tools\":[{\"functionDeclarations\":[{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"required\":[\"city\"],\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}}}}]}]}"
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"What is the weather in Paris?\"}]},{\"role\":\"model\",\"parts\":[{\"functionCall\":{\"name\":\"lookup_weather\",\"args\":{\"city\":\"Paris\"}},\"thoughtSignature\":\"skip_thought_signature_validator\"}]},{\"role\":\"user\",\"parts\":[{\"functionResponse\":{\"name\":\"lookup_weather\",\"response\":{\"name\":\"lookup_weather\",\"content\":\"18C, light rain\"}}}]}],\"tools\":[{\"functionDeclarations\":[{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"required\":[\"city\"],\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}}}}]}]}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "data: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"The weather\"}]}}],\"usageMetadata\": {\"trafficType\": \"ON_DEMAND\"},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-09-22T03:46:29.830958Z\",\"responseId\": \"Ffqxau7bMoCOrb8P_Iu5sA0\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \" in Paris is currently 18°C with light rain.\"}]}}],\"usageMetadata\": {\"trafficType\": \"ON_DEMAND\"},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-09-22T03:46:29.830958Z\",\"responseId\": \"Ffqxau7bMoCOrb8P_Iu5sA0\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"\",\"thoughtSignature\": \"AY89a18aVI8Uxr2VaOI822u0DyuQdy4B00uhLnjqYb5Qb6Mkscccm018knLtYThB5UX8dRv1VFsORSQ0Qo6Gx9RCng2AK9EPce7p\"}]},\"finishReason\": \"STOP\"}],\"usageMetadata\": {\"promptTokenCount\": 59,\"candidatesTokenCount\": 15,\"totalTokenCount\": 74,\"trafficType\": \"ON_DEMAND\",\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 59}],\"candidatesTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 15}]},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-09-22T03:46:29.830958Z\",\"responseId\": \"Ffqxau7bMoCOrb8P_Iu5sA0\"}\r\n\r\n"
"body": "data: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"The weather in Paris is currently 18°C with light rain.\"}]}}],\"usageMetadata\": {\"trafficType\": \"ON_DEMAND\"},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:51.220919Z\",\"responseId\": \"LyyLave9DbWnrb8P1IjLmQQ\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"\",\"thoughtSignature\": \"AY89a197c+fpHJftPtcufnqMAyoRQKVEQK+KeG+RVHVx2wKil3L4jP4YWvfVbcuOFr2jio4Kre/hCrDANAoMFSvaZrdaPeo1b5bXQSmJKMH03yM5M6q6ME6JiBvXym143U4exIde4UbOh2tMeyXMvB3aWxcavIHd78g5G5QPLreo6A3LO5871cYYVeRwteY+/zbEdqfaAq1hlk6WYpWkNljYpjMyKwr15YC8rFLh3HYayS9tTN++GGrk/reZn6C3OEPlzPou/pXRATzcEAGVl/TW\"}]},\"finishReason\": \"STOP\"}],\"usageMetadata\": {\"promptTokenCount\": 59,\"candidatesTokenCount\": 15,\"totalTokenCount\": 98,\"trafficType\": \"ON_DEMAND\",\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 59}],\"candidatesTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 15}],\"thoughtsTokenCount\": 24},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:51.220919Z\",\"responseId\": \"LyyLave9DbWnrb8P1IjLmQQ\"}\r\n\r\n"
}
}
]
@@ -1,13 +1,9 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:google-vertex",
"provider:google-vertex",
"protocol:gemini"
],
"tags": ["prefix:google-vertex", "provider:google-vertex", "protocol:gemini"],
"name": "google-vertex/streams-text",
"recordedAt": "2026-09-22T03:46:28.840Z"
"recordedAt": "2026-08-23T17:21:50.112Z"
},
"interactions": [
{
@@ -25,7 +21,7 @@
"headers": {
"content-type": "text/event-stream"
},
"body": "data: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"Hello\"}]}}],\"usageMetadata\": {\"trafficType\": \"ON_DEMAND\"},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-09-22T03:46:26.748531Z\",\"responseId\": \"EvqxavPXLbqerb8P1eSViQs\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"\",\"thoughtSignature\": \"AY89a18msz0aRAZc4U/Zl7rEXjdTJsE0EWee1+hD2dT7h711q6uIRem7KWCLlji7JVSu2hDV1L8uFh8SGAO5jU4MBSZ+mq661EVXf9DZBF1CV+fetSpCUggurbYHWGGGvLRxjI96W0wR93EMoV/1pLHa4fgGVcFupoFsz/3n9J3wCkT4pRvHjn+hC7V11aSMI07t/OUeo1ssAziob9apuB+HUm2/EO17ph9TZvg3rydE5J0nmJ+DnYGLl5FfQJHgnnsjR5qaKs5wVNIKNA811lv/K5Iee/O6gMNXI3GAcGUvPKHMD5oQSs5B343blFUiqw47H1WZ9KWBoLXhVcnss6hLPmpDchInVhr3ObAHQhZIaBDBnhQR6nuc5BtgoFnwWw3w5G1yNNFt3qT3c75Rd/8NamdtveyskDbrHxQ5GkUo7ICuE/3SQG0yqWhVDjakYgVqjLG3BTKRg6I/dT5+VCeQbdxTZ+ScWkJfXYDEnlZlTjMLz2FS+B+9Zwez/1Dy/pm9H51jvuXI942kv31DfDVcTTwvKmb3SqIB9D4aomb16gDAywC7I4L/+ZwsqmcQoUvVtHGEe5NL3r6fgWtWzKht+60ZgBalmzdBfhZHd/bDXvTAX7SVpHraXjfpcdGrtv1LhU6Kr3gol94TR5Xh9dWKNhwR0s6at7MNCpj3eG9ogrWkmYSiqNeVT1ovscWvH70tDCtCNwY5sIUJVUSWdHc2OdzFxNIPkzh9OuHIn0o9Dbmup6obFoQOgi587HA/Sqvre9pDVqVMzn7w57naDmg+lsR9wS67BxbV4tVrvvfeVQAQQRft5SmSu7WCBnaWuM2/o890nE5ynFG+M2FqApJLgdfXozOguo/hROrD3WxLUyT8+FDsoCY8ky6YYTaF3OlOHpuD5Zmw4UGSdDbcEzhc6cRqBAYfMpxzN58VwNRrjG/MGTY3jrvOLJDk3sF7URa7Tn5YuRLp/2jL5YKWF2QftWiW9jl1YeORYQwNdLBuRK9L4/WOxNRWhYZToAJfwT4byh5dKzY8OnjeFcJy8RVvuRpoYeQY1P63ie/K1D3TgJEwKQXylLdPwtZRGM/P5sm2dcf5F+lRfZGufmv+Q++unrqWr6uv3IlgVGKPa8zTh+ipSaik7taKd+jertOSYkgjrrUfjC0/oyg5giuNqXh+ebIxQC7TMX8bManc5N3aomFuYXmIJ4iT/euQctq8N3gPPWmUNmrnXztdEZgpQdV2ahj+yRr1yxCriOfRo3Az3oPMPEaXxrqDc8RoWt/lOgzzp7KfBGoOVzYdvaDGr8MhsZ8Pd7HD0vZW8TNSl8R7cEK3G5EotNA+s6lF/n/RH5ewmg3cQK8bnnZ4oNjqYo3pGtw+H2rbefMMzAk4mybGe9uDqSdhWJgBzRGsMXRW+0atn9tuJTu6vPNn9asNvwXS0lcvpQskbe5sK3yhzyFzqwrJxs7Ji6yiu6kOtsQ5Girzk1835A2Zun6ZegGtQH8DbgIPdSBMkh6kypndBu4ns2gg97g9yB8XFGbv3c60sxaXPRuycl9IjBL81Yy6n6PCOBx7Pqm9xrzPn7QzgtetKxCmNUMYJvF+myiQFrMdL1w+1DD7FoZX9VNBDbNWgcfhgnL8tlHSmTb36KnScJOe8D+3n3+aDfkWpiHjrLrGJsHlaek/Ji2Cgwa0dIC6EhQitNttskIKVMa+yD6D1T6Dvz/eolnutjYwbGb3y9mtpQzmVwf2Zp2dzxYRWZk=\"}]},\"finishReason\": \"STOP\"}],\"usageMetadata\": {\"promptTokenCount\": 7,\"candidatesTokenCount\": 1,\"totalTokenCount\": 285,\"trafficType\": \"ON_DEMAND\",\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 7}],\"candidatesTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 1}],\"thoughtsTokenCount\": 277},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-09-22T03:46:26.748531Z\",\"responseId\": \"EvqxavPXLbqerb8P1eSViQs\"}\r\n\r\n"
"body": "data: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"Hello\"}]}}],\"usageMetadata\": {\"trafficType\": \"ON_DEMAND\"},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:48.528714Z\",\"responseId\": \"LCyLasqiIO6crb8P1sDboQc\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"\",\"thoughtSignature\": \"AY89a1+BGsRqlGpfT0psLB4jeTkT5rDV2HFOlrRuF7aVxDOjqNVUku6t4azeSnxpd+msHWuwXj4RS+7gmVlzVs+JNi8uj+iZWTBCi71vSh9kdK9ed/sHv9J7uL9ZWSOcgbhX/hxdXaUp5yVbQzHFXPjR9A/IkEkHV8VKarDZVFE1T1uASia74lkmyBeZZz+DQmRsLwbUHzFUKlF3qnk/SliLo21ZgASd7itlALQ0PBLJZwgeI3g7tDscDSE18hnB11Fky8q7MLd3HY16zbDvHBEMb18pmmPelPI01KdrCIwMSou/01/u5jiSUCc3pFksZawUj3tAHocHSC3ZKAQQQuUXGe5tm61C2E40/NANBeePc1S4HYE6Yo/vtX6tE02LDky5IQWX09H6+DZ7fpopP5nCUfcKPHa3hVjYquWYYMtZgXO4ZpxfVd3lt1VUDuJNN3BMMCZapjBoJZFPXPJ5t/yg9Rnd791+msGH77b4wztz1vtsPrT9oV9g6SDo9ZUH6BaOcbK7fw8FaXcGw+55malEwQy6zpRLGecooBu70p6RwhaAUyKIMX49y+F2hkNxQxDeBUNckJnu6n4w+KLyjP+bR0gqPJbGjVfteHm+QujqjJdBBT/m1u9kPo1nIbzdEs/PIADBdbuV7TkD/HoRFKpLnNmM2no8ioTtFEjKBDz4ippGi15r8pGgA6wIb/1HAvOGh+PVERdGcbelVTgfONwBqjQ7B1wmEizCfyYuMIskfwjxDGayfKlpDxrnNeogtEct9u5/DjEKlURlg9MtmW1B9P8BXYJ+7SCiRJWwW6bzB+5C+MLCnETl/mljDizoJMHK8DKIhI4oxBsrWXEuoHFwEwGIeOZq0BofH2Jz/l6+KIboV/zd581Kk0zPg/rlI6acfjUEtXtbF+t0+jzoJN7006x4i2tqXeJZ+4e5yisSArEsfJ0YzNWoJtBHG9V9/euDcEP3+jsr98efaQaQbLMPvT/Hb7CYQ7ChhGfcGxQ=\"}]},\"finishReason\": \"STOP\"}],\"usageMetadata\": {\"promptTokenCount\": 7,\"candidatesTokenCount\": 1,\"totalTokenCount\": 150,\"trafficType\": \"ON_DEMAND\",\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 7}],\"candidatesTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 1}],\"thoughtsTokenCount\": 142},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:48.528714Z\",\"responseId\": \"LCyLasqiIO6crb8P1sDboQc\"}\r\n\r\n"
}
}
]
@@ -1,33 +0,0 @@
{
"version": 1,
"metadata": {
"model": "jev-1.13-free",
"tags": [
"prefix:opencode-zen-evaluation",
"provider:opencode",
"protocol:system-one"
],
"name": "opencode-zen-evaluation/evaluates-choice-score-and-boolean-questions",
"recordedAt": "2026-09-22T03:44:57.511Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://opencode.ai/zen/v1/systemone",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"jev-1.13-free\",\"state\":\"I was charged twice for the same invoice. Please refund the duplicate payment today.\",\"questions\":{\"department\":{\"type\":\"choice\",\"instructions\":\"Which team should handle this support request?\",\"criteria\":{\"billing\":\"Payments, invoices, refunds, or failed charges\",\"technical\":\"Bugs, outages, or integrations\",\"sales\":\"Pricing, upgrades, or new accounts\"}},\"urgency\":{\"type\":\"score\",\"instructions\":\"How urgent is this support request?\",\"criteria\":[\"Can wait for normal support\",\"Needs prompt attention\",\"Actively blocking revenue\"]},\"refund\":{\"type\":\"noul\",\"instructions\":\"Is the customer asking for a refund?\"}}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"jev-1.13-free\",\"answers\":{\"department\":{\"type\":\"choice\",\"choice\":\"billing\",\"confidence\":1,\"probabilities\":{\"technical\":0,\"sales\":0,\"billing\":1}},\"urgency\":{\"type\":\"score\",\"score\":1.02,\"confidence\":0.95,\"legend\":{\"0\":\"Can wait for normal support\",\"1\":\"Needs prompt attention\",\"2\":\"Actively blocking revenue\"},\"probabilities\":{\"0\":0,\"1\":0.97,\"2\":0.03}},\"refund\":{\"type\":\"noul\",\"noul\":0.99}},\"usage\":{\"input_tokens\":422,\"output_tokens\":69}}"
}
}
]
}
@@ -1,33 +0,0 @@
{
"version": 1,
"metadata": {
"model": "jev-latest",
"tags": [
"prefix:typesafe-evaluation",
"provider:typesafe-ai",
"protocol:system-one"
],
"name": "typesafe-evaluation/evaluates-choice-score-and-boolean-questions",
"recordedAt": "2026-09-22T03:44:28.626Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.typesafe.ai/v1/systemone",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"jev-latest\",\"state\":\"I was charged twice for the same invoice. Please refund the duplicate payment today.\",\"questions\":{\"department\":{\"type\":\"choice\",\"instructions\":\"Which team should handle this support request?\",\"criteria\":{\"billing\":\"Payments, invoices, refunds, or failed charges\",\"technical\":\"Bugs, outages, or integrations\",\"sales\":\"Pricing, upgrades, or new accounts\"}},\"urgency\":{\"type\":\"score\",\"instructions\":\"How urgent is this support request?\",\"criteria\":[\"Can wait for normal support\",\"Needs prompt attention\",\"Actively blocking revenue\"]},\"refund\":{\"type\":\"noul\",\"instructions\":\"Is the customer asking for a refund?\"}}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"jev-1.13.0\",\"answers\":{\"department\":{\"type\":\"choice\",\"choice\":\"billing\",\"confidence\":1.0,\"probabilities\":{\"billing\":1.0,\"sales\":0.0,\"technical\":0.0}},\"urgency\":{\"type\":\"score\",\"score\":1.01,\"confidence\":0.97,\"legend\":{\"0\":\"Can wait for normal support\",\"1\":\"Needs prompt attention\",\"2\":\"Actively blocking revenue\"},\"probabilities\":{\"0\":0.0,\"1\":0.98,\"2\":0.02}},\"refund\":{\"type\":\"noul\",\"noul\":0.99}},\"usage\":{\"input_tokens\":422,\"output_tokens\":69}}"
}
}
]
}
@@ -1,79 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Evaluation } from "../../src/experimental.js"
import { OpenCodeZen, TypeSafeAI } from "../../src/providers.js"
import { recordedTests } from "../recorded-test.js"
const questions = {
department: {
type: "choice",
instructions: "Which team should handle this support request?",
criteria: {
billing: "Payments, invoices, refunds, or failed charges",
technical: "Bugs, outages, or integrations",
sales: "Pricing, upgrades, or new accounts",
},
},
urgency: {
type: "score",
instructions: "How urgent is this support request?",
criteria: ["Can wait for normal support", "Needs prompt attention", "Actively blocking revenue"],
},
refund: { type: "boolean", instructions: "Is the customer asking for a refund?" },
} as const
const state = "I was charged twice for the same invoice. Please refund the duplicate payment today."
const typesafe = recordedTests({
prefix: "typesafe-evaluation",
provider: "typesafe-ai",
protocol: "system-one",
requires: ["TYPESAFE_API_KEY"],
metadata: { model: "jev-latest" },
})
const zen = recordedTests({
prefix: "opencode-zen-evaluation",
provider: "opencode",
protocol: "system-one",
requires: ["OPENCODE_API_KEY"],
metadata: { model: "jev-1.13-free" },
})
describe("experimental Evaluation recorded", () => {
typesafe.effect("evaluates choice score and boolean questions", () =>
assertEvaluation(
TypeSafeAI.configure({ apiKey: process.env.TYPESAFE_API_KEY ?? "fixture" }).experimental.evaluation("jev-latest"),
"typesafe",
),
)
zen.effect("evaluates choice score and boolean questions", () =>
assertEvaluation(
OpenCodeZen.configure({ apiKey: process.env.OPENCODE_API_KEY ?? "fixture" }).experimental.evaluation(
"jev-1.13-free",
),
"opencode",
),
)
})
const assertEvaluation = (
model: ReturnType<typeof TypeSafeAI.experimental.evaluation>,
metadataKey: "typesafe" | "opencode",
) =>
Effect.gen(function* () {
const response = yield* Evaluation.evaluate({ model, state, questions })
expect(response.model).toStartWith("jev-")
expect(response.answers.department.type).toBe("choice")
expect(response.answers.department.choice).toBe("billing")
expect(response.answers.department.probabilities?.billing).toBeGreaterThan(0.5)
expect(response.answers.urgency.type).toBe("score")
expect(response.answers.urgency.score).toBeGreaterThanOrEqual(0)
expect(response.answers.urgency.score).toBeLessThanOrEqual(2)
expect(response.answers.refund.type).toBe("boolean")
expect(response.answers.refund.probability).toBeGreaterThan(0.5)
expect(response.usage?.inputTokens).toBeGreaterThan(0)
expect(response.usage?.outputTokens).toBeGreaterThan(0)
expect(response.providerMetadata?.[metadataKey]?.confidence).toBeDefined()
})
@@ -114,7 +114,7 @@ describe("Google Vertex providers", () => {
}),
)
it.effect("preserves function call ids in lowered Vertex bodies", () =>
it.effect("strips function call ids Vertex does not accept from lowered bodies", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
@@ -142,14 +142,15 @@ describe("Google Vertex providers", () => {
}),
)
expect(JSON.stringify(prepared.body.contents)).not.toContain('"id"')
expect(prepared.body.contents).toMatchObject([
{ role: "model", parts: [{ functionCall: { id: "call_1", name: "lookup", args: { query: "weather" } } }] },
{ role: "model", parts: [{ functionCall: { id: undefined, name: "lookup", args: { query: "weather" } } }] },
{
role: "user",
parts: [
{
functionResponse: {
id: "call_1",
id: undefined,
name: "lookup",
response: { name: "lookup", content: "sunny" },
},
@@ -234,23 +235,12 @@ describe("Google Vertex providers", () => {
parts: [
{ text: "Thinking.", thought: true, thoughtSignature: "reasoning_sig" },
{ text: "Checking.", thoughtSignature: "text_sig" },
{
functionCall: { id: "provider_call_1", name: "lookup", args: { query: "weather" } },
thoughtSignature: "tool_sig",
},
{ functionCall: { name: "lookup", args: { query: "weather" } }, thoughtSignature: "tool_sig" },
],
},
{
role: "user",
parts: [
{
functionResponse: {
id: "provider_call_1",
name: "lookup",
response: { name: "lookup", content: "sunny" },
},
},
],
parts: [{ functionResponse: { name: "lookup", response: { name: "lookup", content: "sunny" } } }],
},
])
}),
@@ -11,78 +11,66 @@ import { sseEvents } from "../lib/sse.js"
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
it.effect("decodes error frames verbatim in shared SSE and WebSocket decoding", () =>
it.effect("normalizes flat errors in shared SSE and WebSocket decoding", () =>
Effect.gen(function* () {
const frame = {
type: "error",
sequence_number: 4,
code: "server_shutting_down",
message: "Server is shutting down. Please retry your request.",
param: null,
}
for (const decode of [decodeEvent, OpenResponses.decodeChannelEvent]) {
for (const frame of [
{ type: "error", sequence_number: 4, code: "server_shutting_down", message: "Shutting down", param: null },
const event = yield* decode(JSON.stringify(frame))
expect(event).toEqual({
type: "error",
sequence_number: 4,
error: { code: frame.code, message: frame.message, param: null },
})
for (const unchanged of [
event,
{ type: "error" },
{ type: "error", error: "Gateway failed" },
{ type: "error", error: { code: 429, message: "slow down" } },
{ type: "error", error: 42 },
{ type: "error", code: 500, message: ["not", "a", "string"] },
{ type: "response.failed", response: { id: "resp_failed", error: "Gateway failed" } },
{ type: "response.failed", response: { id: "resp_failed", error: ["weird"] } },
{
type: "response.failed",
response: { id: "resp_failed", error: { code: "server_error", message: "Internal server error" } },
},
{ type: "response.output_text.delta", item_id: "msg_text", delta: "Hello" },
]) {
expect(yield* decode(JSON.stringify(frame))).toEqual(frame)
expect(yield* decode(JSON.stringify(unchanged))).toEqual(unchanged)
}
}
}),
)
it.effect("reads bare WebSocket error envelopes as error events", () =>
it.effect("continues to normalize untyped xAI WebSocket errors", () =>
Effect.gen(function* () {
const message = "gRPC error: Response with id=resp_missing not found"
for (const error of [{ type: "api_error", message }, message, 42]) {
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify({ error }))).toEqual({ type: "error", error })
}
for (const frame of [{ error: null }, { message }]) {
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify(frame)).pipe(Effect.flip)).toBeDefined()
for (const error of [{ type: "api_error", message }, message]) {
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify({ error }))).toEqual({
type: "error",
error: typeof error === "string" ? { message } : error,
})
}
}),
)
it.effect("extracts error details from every shape and falls back to the raw frame", () =>
it.effect("normalizes string errors in shared SSE and WebSocket decoding", () =>
Effect.gen(function* () {
const cases: Array<[frame: Record<string, unknown>, message: string, tag: string]> = [
[
{ type: "error", code: "server_shutting_down", message: "Shutting down" },
"server_shutting_down: Shutting down",
"UnknownProvider",
],
[{ type: "error", error: "Gateway failed" }, "Gateway failed", "UnknownProvider"],
[{ type: "error", error: { code: 429, message: "slow down" } }, "429: slow down", "UnknownProvider"],
[{ type: "error", error: { message: "slow down" }, status: 429 }, "slow down", "RateLimit"],
[{ type: "error", code: 500, message: ["not", "a", "string"] }, "500", "UnknownProvider"],
[
{ type: "response.failed", response: { id: "resp_failed", error: "Gateway failed" } },
"Gateway failed",
"UnknownProvider",
],
]
for (const [frame, message, tag] of cases) {
const event = yield* OpenResponses.decodeChannelEvent(JSON.stringify(frame))
const error = OpenResponses.providerFailure(event, "fallback", JSON.stringify(frame))
expect(error.message).toBe(message)
expect(error.reason._tag).toBe(tag)
expect(error.reason.body).toBe(JSON.stringify(frame))
for (const decode of [decodeEvent, OpenResponses.decodeChannelEvent]) {
expect(yield* decode(JSON.stringify({ type: "error", error: "Gateway failed" }))).toEqual({
type: "error",
error: { message: "Gateway failed" },
})
expect(
yield* decode(
JSON.stringify({ type: "response.failed", response: { id: "resp_failed", error: "Gateway failed" } }),
),
).toEqual({
type: "response.failed",
response: { id: "resp_failed", error: { message: "Gateway failed" } },
})
}
for (const frame of [
{ type: "error", error: 42 },
{ type: "response.failed", response: { id: "resp_failed", error: ["weird"] } },
]) {
const event = yield* OpenResponses.decodeChannelEvent(JSON.stringify(frame))
const error = OpenResponses.providerFailure(event, "fallback", JSON.stringify(frame))
expect(error.message).toBe(JSON.stringify(frame))
expect(error.reason._tag).toBe("UnknownProvider")
}
expect(OpenResponses.providerFailure({ type: "error" }, "fallback", "{}").message).toBe("fallback")
expect(OpenResponses.providerFailure({ type: "error" }, "fallback", "{}").reason._tag).toBe("ProviderInternal")
}),
)
+1 -9
View File
@@ -6,8 +6,6 @@ import * as path from "node:path"
import { fileURLToPath } from "node:url"
import { LLMClient, RequestExecutor } from "../src/route.js"
import { ImageClient } from "../src/image-client.js"
import { EvaluationClient } from "../src/experimental/evaluation-client.js"
import type { Service as EvaluationClientService } from "../src/experimental/evaluation-client.js"
import type { Service as ImageClientService } from "../src/image-client.js"
import type { Service as LLMClientService } from "../src/route/client.js"
import type { Service as RequestExecutorService } from "../src/route/executor.js"
@@ -20,12 +18,7 @@ import {
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings")
type RecordedEnv =
| RequestExecutorService
| LLMClientService
| ImageClientService
| EvaluationClientService
| Socket.WebSocketConstructor
type RecordedEnv = RequestExecutorService | LLMClientService | ImageClientService | Socket.WebSocketConstructor
type RecordedTestsOptions = RecordedGroupOptions & {
readonly options?: HttpRecorder.RecorderOptions
@@ -99,7 +92,6 @@ export const recordedTests = (options: RecordedTestsOptions) =>
requestExecutor,
LLMClient.layer.pipe(Layer.provide(requestExecutor)),
ImageClient.layer.pipe(Layer.provide(requestExecutor)),
EvaluationClient.layer.pipe(Layer.provide(requestExecutor)),
webSocket,
)
},
@@ -19,13 +19,6 @@ export function mountBrowserPane() {
session: "Alpha",
mounted: true,
visible: true,
url: undefined as string | undefined,
loading: false,
generation: 0,
delayNavigation: false,
pendingURL: undefined as string | undefined,
loadErrors: {} as Record<string, string | undefined>,
error: undefined as string | undefined,
layouts: {} as Record<string, BrowserPaneLayout | undefined>,
})
const tabs = ["Alpha", "Beta"].map((name) => ({
@@ -54,33 +47,13 @@ export function mountBrowserPane() {
opened: () => !!registrations.get(store.session),
state: () => ({ tabs: tabs.filter((tab) => tab.title === store.session), focusedTabID: null }),
tabs: () => tabs.filter((tab) => tab.title === store.session),
active: () => {
const tab = tabs.find((tab) => tab.title === store.session) ?? tabs[0]
return {
...tab,
url: store.url ?? tab.url,
loading: store.loading,
generation: store.generation,
loadError: store.loadErrors[store.session],
}
},
active: () => tabs.find((tab) => tab.title === store.session) ?? tabs[0],
registration: () => registrations.get(store.session),
error: () => store.error ?? (store.loadErrors[store.session] ? "Request failed" : undefined),
error: () => undefined,
suspended: () => false,
close: () => undefined,
open: () => undefined,
command: (command) => {
setStore("error", undefined)
if (command.type === "navigate" || command.type === "reload") setStore("loadErrors", store.session, undefined)
if (command.type === "navigate") {
if (store.delayNavigation) {
setStore("pendingURL", command.url)
return
}
setStore({ url: command.url, loading: false, generation: store.generation + 1 })
}
if (command.type === "stop") setStore("loading", false)
},
command: () => undefined,
}
return (
<>
@@ -91,32 +64,6 @@ export function mountBrowserPane() {
{(name) => <button onClick={() => setStore({ session: name, mounted: name !== "Empty" })}>{name}</button>}
</For>
<button onClick={() => setStore("mounted", false)}>Unmount pane</button>
<button onClick={() => setStore({ url: "about:blank", loading: false })}>Blank page</button>
<button onClick={() => setStore({ url: "about:blank", loading: true })}>Loading page</button>
<button
onClick={() =>
setStore({ loading: true, generation: store.generation + 1, loadErrors: { [store.session]: undefined } })
}
>
Load current page
</button>
<button onClick={() => setStore("loadErrors", store.session, "ERR_CONNECTION_REFUSED")}>Failed page</button>
<button onClick={() => setStore("delayNavigation", true)}>Delay navigation</button>
<button onClick={() => setStore({ error: "ERR_BLOCKED_BY_CLIENT", pendingURL: undefined })}>
Block navigation
</button>
<button
onClick={() =>
setStore({
url: store.pendingURL,
pendingURL: undefined,
loading: false,
generation: store.generation + 1,
})
}
>
Complete navigation
</button>
<button onClick={() => setStore("visible", (visible) => !visible)}>Toggle Review tab</button>
</nav>
<div style={{ width: "640px", height: "360px", border: "1px solid #555" }}>
@@ -57,153 +57,3 @@ story("hides the native view immediately while the pane stays mounted", async ({
await toggle.click()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
})
story("shows the empty state over a blank native page and restores navigation", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Blank page", exact: true }).click()
await expect(root.getByText("Enter URL", { exact: true })).toBeVisible()
await expect(root.getByText('Or prompt "Open in the app browser"', { exact: true })).toBeVisible()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
await expect(root.getByRole("button", { name: "Reload", exact: true })).toBeDisabled()
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
await address.fill("https://example.com/")
await expect(root.getByText("Enter URL", { exact: true })).toBeVisible()
await address.press("Enter")
await expect(address).not.toBeFocused()
await expect(root.getByText("Enter URL", { exact: true })).toBeHidden()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await expect(root.getByRole("button", { name: "Reload", exact: true })).toBeEnabled()
})
story("keeps Stop available and hides the empty state while a blank page loads", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Loading page", exact: true }).click()
await expect(root.getByText("Enter URL", { exact: true })).toBeHidden()
await root.getByRole("button", { name: "Stop", exact: true }).click()
await expect(root.getByText("Enter URL", { exact: true })).toBeVisible()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
})
story("keeps the submitted URL visible until the browser reports navigation", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Blank page", exact: true }).click()
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
await address.fill("https://example.com/")
await address.press("Enter")
await expect(address).not.toBeFocused()
await expect(address).toHaveValue("https://example.com/")
await expect(root.getByText("Enter URL", { exact: true })).toBeHidden()
await root.getByRole("button", { name: "Complete navigation", exact: true }).click()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await expect(address).toHaveValue("https://example.com/")
await address.fill("https://unsubmitted.example/")
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
await expect(address).toHaveValue("https://example.com/")
})
story("restores the current URL when a submitted navigation is blocked", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
await address.fill("https://blocked.example/")
await address.press("Enter")
await expect(address).toHaveValue("https://blocked.example/")
await root.getByRole("button", { name: "Block navigation", exact: true }).click()
await expect(root.getByText("ERR_BLOCKED_BY_CLIENT", { exact: true })).toBeVisible()
await expect(address).toHaveValue("https://alpha.example/")
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
})
story("shows a themed failure state for only the failed tab and allows retry", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Failed page", exact: true }).click()
await expect(root.getByText("URL can't be reached", { exact: true })).toBeVisible()
await expect(root.getByText("Check the URL and your connection, then try again.", { exact: true })).toBeVisible()
await expect(root.getByText("Request failed", { exact: true })).toBeHidden()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
await expect(root.getByRole("textbox", { name: "Browser address", exact: true })).toHaveValue(
"https://alpha.example/",
)
await root.getByRole("button", { name: "Beta", exact: true }).click()
await expect(root.getByText("URL can't be reached", { exact: true })).toBeHidden()
await expect(root.getByTestId("native-Beta")).toHaveAttribute("data-visible", "true")
await root.getByRole("button", { name: "Alpha", exact: true }).click()
await expect(root.getByText("URL can't be reached", { exact: true })).toBeVisible()
await root.getByRole("button", { name: "Reload", exact: true }).click()
await expect(root.getByText("URL can't be reached", { exact: true })).toBeHidden()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
})
story("returns a failed tab to the empty state when an empty URL is submitted", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Failed page", exact: true }).click()
await expect(root.getByText("URL can't be reached", { exact: true })).toBeVisible()
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
await address.fill("")
await address.press("Enter")
await expect(address).not.toBeFocused()
await expect(address).toHaveValue("")
await root.getByRole("button", { name: "Load current page", exact: true }).click()
await expect(root.getByRole("button", { name: "Stop", exact: true })).toBeEnabled()
await root.getByRole("button", { name: "Complete navigation", exact: true }).click()
await expect(root.getByText("URL can't be reached", { exact: true })).toBeHidden()
await expect(root.getByText("Enter URL", { exact: true })).toBeVisible()
await expect(root.getByRole("button", { name: "Reload", exact: true })).toBeDisabled()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
})
story("selects the full URL when the address field gains focus", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
await address.click()
await expect(address).toHaveJSProperty("selectionStart", 0)
await expect(address).toHaveJSProperty("selectionEnd", "https://alpha.example/".length)
await address.pressSequentially("https://example.com/")
await expect(address).toHaveValue("https://example.com/")
await address.press("Enter")
await expect(address).not.toBeFocused()
await address.focus()
await expect(address).toHaveJSProperty("selectionStart", 0)
await expect(address).toHaveJSProperty("selectionEnd", "https://example.com/".length)
await address.press("ArrowRight")
await address.click()
await expect(address).toHaveJSProperty("selectionStart", 0)
await expect(address).toHaveJSProperty("selectionEnd", "https://example.com/".length)
})
story("keeps the current page visible while a submitted URL loads", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
await address.fill("https://example.com/")
await address.press("Enter")
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await expect(address).toHaveValue("https://example.com/")
await root.getByRole("button", { name: "Load current page", exact: true }).click()
await expect(root.getByRole("button", { name: "Stop", exact: true })).toBeEnabled()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await root.getByRole("button", { name: "Complete navigation", exact: true }).click()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await expect(address).toHaveValue("https://example.com/")
})
story("keeps the current page and restores its URL when an empty address is submitted", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
await address.fill("")
await address.press("Enter")
await expect(address).not.toBeFocused()
await expect(address).toHaveValue("https://alpha.example/")
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await expect(root.getByText("Enter URL", { exact: true })).toBeHidden()
await expect(root.getByRole("button", { name: "Reload", exact: true })).toBeEnabled()
})
@@ -17,12 +17,14 @@ const milestones = [
"electronSpawnStarted",
"debugEndpointReady",
"electronStarted",
"layersReady",
"windowVisible",
"serviceEnsureStarted",
"serviceSpawnRequested",
"serviceReady",
"backgroundLoadingReady",
"rendererViteConnected",
"rendererInitializationStarted",
"rendererInitializationReady",
"windowVisible",
"homeReady",
] as const
const phases = [
@@ -126,12 +128,14 @@ const milestonePatterns: ReadonlyArray<{ name: Milestone; text: string }> = [
{ name: "electronSpawnStarted", text: "starting electron app..." },
{ name: "debugEndpointReady", text: "DevTools listening on ws://" },
{ name: "electronStarted", text: "app starting" },
{ name: "layersReady", text: "layers ready" },
{ name: "windowVisible", text: "main window visible" },
{ name: "serviceEnsureStarted", text: "starting v2 background service" },
{ name: "serviceSpawnRequested", text: "v2 CLI background service starting" },
{ name: "serviceReady", text: "v2 CLI background service ready" },
{ name: "backgroundLoadingReady", text: "loading task finished" },
{ name: "rendererViteConnected", text: "[vite] connected." },
{ name: "rendererInitializationStarted", text: "awaiting server ready" },
{ name: "rendererInitializationReady", text: "server ready" },
{ name: "windowVisible", text: "main window visible" },
]
async function createColdProfile() {
@@ -400,12 +404,14 @@ function requireMilestones(observed: Partial<Record<Milestone, number>>) {
electronSpawnStarted: get("electronSpawnStarted"),
debugEndpointReady: get("debugEndpointReady"),
electronStarted: get("electronStarted"),
layersReady: get("layersReady"),
windowVisible: get("windowVisible"),
serviceEnsureStarted: get("serviceEnsureStarted"),
serviceSpawnRequested: get("serviceSpawnRequested"),
serviceReady: get("serviceReady"),
backgroundLoadingReady: get("backgroundLoadingReady"),
rendererViteConnected: get("rendererViteConnected"),
rendererInitializationStarted: get("rendererInitializationStarted"),
rendererInitializationReady: get("rendererInitializationReady"),
windowVisible: get("windowVisible"),
homeReady: get("homeReady"),
}
}
@@ -703,7 +703,7 @@ function messageContent(
return {
type: "reasoning",
text: part.text,
native: jsonRecord(part.metadata),
state: jsonRecord(part.metadata),
time: part.time
? { created: part.time.start, ...(part.time.end === undefined ? {} : { completed: part.time.end }) }
: undefined,
@@ -20,12 +20,14 @@ describe("desktop startup benchmark", () => {
["electronSpawnStarted", "starting electron app..."],
["debugEndpointReady", "DevTools listening on ws://"],
["electronStarted", "app starting"],
["layersReady", "layers ready"],
["windowVisible", "main window visible"],
["serviceEnsureStarted", "starting v2 background service"],
["serviceSpawnRequested", "v2 CLI background service starting"],
["serviceReady", "v2 CLI background service ready"],
["backgroundLoadingReady", "loading task finished"],
["rendererViteConnected", "[vite] connected."],
["rendererInitializationStarted", "awaiting server ready"],
["rendererInitializationReady", "server ready"],
["windowVisible", "main window visible"],
] as const
cases.forEach(([milestone, line]) => {
expect(milestoneForLine(`\u001b[32m${line}\u001b[39m`)).toBe(milestone)
@@ -56,12 +58,14 @@ function sample(run: number, commandToHomeReadyMs: number): DesktopStartupSample
electronSpawnStarted: 7,
debugEndpointReady: 8,
electronStarted: 9,
layersReady: 10,
windowVisible: 11,
serviceEnsureStarted: 12,
serviceSpawnRequested: 13,
serviceReady: 14,
rendererViteConnected: 15,
serviceEnsureStarted: 10,
serviceSpawnRequested: 11,
serviceReady: 12,
backgroundLoadingReady: 13,
rendererViteConnected: 14,
rendererInitializationStarted: 15,
rendererInitializationReady: 16,
windowVisible: 17,
homeReady: commandToHomeReadyMs,
}
return {
@@ -76,8 +80,8 @@ function sample(run: number, commandToHomeReadyMs: number): DesktopStartupSample
electronStartup: 2,
serviceSpawnWait: 1,
serviceProcessStartup: 1,
rendererStartup: commandToHomeReadyMs - 15,
visibleWindowToHome: commandToHomeReadyMs - 11,
rendererStartup: commandToHomeReadyMs - 14,
visibleWindowToHome: commandToHomeReadyMs - 17,
},
service: { version: "2.0.0-local-test", url: "http://127.0.0.1:3000", pid: run },
}
@@ -71,7 +71,7 @@ for (const direction of ["ltr", "rtl"] as const) {
const shifted = await content.evaluate((element) => getComputedStyle(element).translate)
await content.evaluate((element) => element.setAttribute("data-summary-motion", ""))
// Keep issuing resize events before the idle timer expires, including crossing the width cutoff.
for (const width of [1520, 1280, 1800]) {
for (const width of [1520, 1280, 1600]) {
await page.setViewportSize({ width, height: 900 })
await expect(panel).toHaveAttribute("data-summary-resizing", "true")
await page.clock.runFor(100)
@@ -84,7 +84,6 @@ for (const direction of ["ltr", "rtl"] as const) {
await expect(content).toHaveAttribute("data-summary-motion", "transitionrun,transitionend,")
await expect(content).not.toHaveCSS("translate", shifted)
await page.setViewportSize({ width: 1440, height: 900 })
await expect(panel).toHaveAttribute("data-summary-resizing", "false")
await expect(content).toHaveCSS("translate", shifted)
await content.evaluate((element) => element.setAttribute("data-summary-motion", ""))
@@ -113,8 +112,7 @@ for (const direction of ["ltr", "rtl"] as const) {
await expect(summary).toBeVisible()
await page.setViewportSize({ width: 1800, height: 900 })
await expect(panel).toHaveAttribute("data-summary-resizing", "false")
await expect(content).toHaveCSS("translate", "none")
await expect(content).toHaveCSS("translate", "0px")
await expect(summary).toBeVisible()
await page.emulateMedia({ reducedMotion: "reduce" })
@@ -116,7 +116,7 @@ test("single-server settings expose scoped pages without a server picker", async
await expect(connection.getByRole("heading", { name: "Connection", exact: true })).toBeVisible()
await expect(connection.locator('[data-component="settings-list"]')).toHaveCSS("padding-left", "16px")
await expect(connection.locator(".settings-servers-row")).toHaveCSS("padding-top", "20px")
await expect(connection.locator(".settings-servers-lead")).toHaveCSS("column-gap", "10px")
await expect(connection.locator(".settings-servers-lead")).toHaveCSS("column-gap", "4px")
await expect(connection.locator(".settings-servers-copy")).toHaveCSS("row-gap", "6px")
await expect(settings.getByRole("heading", { name: "Preferences", exact: true })).toBeVisible()
await expect(settings.getByText("Terminal shell", { exact: true })).toBeVisible()
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "2.0.12",
"version": "2.0.10",
"description": "",
"type": "module",
"exports": {
+4 -4
View File
@@ -65,14 +65,14 @@
}
/* Keep the 1000px conversation intact, with 320px for the summary and its gutters.
When they overlap, center the conversation in the space that remains beside the summary. */
@container session-chat (min-width: 1320px) and (width < 1640px) {
Move only as far as needed; wider panels already have enough space in the margin. */
@container session-chat (min-width: 1320px) {
[data-slot="session-chat-panel"][data-summary-open="true"]
:is([data-timeline-virtual-content], [data-component="session-composer-dock"] > div) {
--session-summary-translate: -160px;
--session-summary-translate: min(0px, calc(50cqi - 820px));
&:dir(rtl) {
--session-summary-translate: 160px;
--session-summary-translate: max(0px, calc(820px - 50cqi));
}
}
}
-14
View File
@@ -625,16 +625,6 @@ export const dict = {
"toast.file.loadFailed.title": "Failed to load file",
"file.error.notFound": "File not found: {{name}}",
"toast.file.listFailed.title": "Failed to list files",
"file.view.preview": "Preview",
"file.view.source": "Source",
"file.view.openInBrowser": "Open in browser",
"file.view.binary": "Binary file · {{size}}",
"file.view.table.rows.one": "{{count}} row",
"file.view.table.rows.other": "{{count}} rows",
"file.view.table.columns.one": "{{count}} column",
"file.view.table.columns.other": "{{count}} columns",
"file.view.table.truncated": "Showing the first {{shown}} of {{total}} rows.",
"file.view.fontSample": "Sphinx of black quartz, judge my vow.",
"toast.context.noLineSelection.title": "No line selection",
"toast.context.noLineSelection.description": "Select a line range in a file tab first.",
@@ -916,10 +906,6 @@ export const dict = {
"session.browser.replaced": "Browser control moved to another desktop window.",
"session.browser.suspended": "Browser suspended. Interact with this session to reconnect.",
"session.browser.address.placeholder": "Enter URL",
"session.browser.empty.title": "Enter URL",
"session.browser.empty.description": 'Or prompt "Open in the app browser"',
"session.browser.failed.title": "URL can't be reached",
"session.browser.failed.description": "Check the URL and your connection, then try again.",
"titlebar.update": "Update",
"titlebar.tabs": "Tabs",
@@ -19,7 +19,6 @@ export type BrowserPaneCommand = Browser.Action
export type BrowserPaneState = Browser.State | null
export type BrowserPaneEvent =
| { type: "focus"; tabID: Browser.TabID }
| { type: "preview"; path: string }
| { type: "state"; state: BrowserPaneState; error?: string }
export type BrowserPaneRegistration = {
-2
View File
@@ -33,8 +33,6 @@ export type FileContent = {
}
encoding?: "base64"
mimeType?: string
/** On-disk size when the bytes themselves are not retained. */
size?: number
}
export type Path = {
+11 -25
View File
@@ -1,15 +1,14 @@
import { batch, createEffect, createMemo, createRoot, getOwner, on, onCleanup, runWithOwner } from "solid-js"
import { createEffect, createMemo, getOwner, on, onCleanup, runWithOwner } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { createSimpleContext } from "@opencode/ui/context"
import type { Browser } from "@opencode/plugin-browser/rpc"
import { useLanguage } from "@/runtime/i18n/language"
import type { BrowserPaneCommand } from "@/runtime/platform/browser-pane"
import { usePlatform } from "@/runtime/platform/platform"
import type { useServer } from "@/runtime/server/current"
import type { SessionStateKey } from "@/runtime/server/scope"
import { useSettings } from "@/settings/model"
import { findSessionTab, tabKey, useTabs } from "@/shell/tabs/tabs"
import { useCurrentRoute, useLayout } from "@/shell/state/layout"
import { sessionBrowserTab } from "@/shell/state/session-tabs"
import { useCurrentRoute } from "@/shell/state/layout"
import { createEventListener } from "@solid-primitives/event-listener"
import { createBrowserConnection, type BrowserConnectionState } from "./connection"
@@ -36,14 +35,13 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider
const settings = useSettings()
const language = useLanguage()
const shellTabs = useTabs()
const layout = useLayout()
const route = useCurrentRoute()
const owner = getOwner()
const [store, setStore] = createStore<Record<string, BrowserAttachment | undefined>>({})
// Servers whose plugin lacks the browser RPC; sessions on them stop retrying.
const [unsupported, setUnsupported] = createStore<Record<string, true | undefined>>({})
const live = new Map<string, Live>()
const preview = new Map<string, Set<(path: string) => void>>()
const focus = new Map<string, Set<(tabID: Browser.TabID) => void>>()
const key = (server: Server, sessionID: string) => `${server.key}\n${sessionID}`
const enabled = createMemo(
() => !!platform.browserPane && settings.ready() && settings.general.experimentalBrowser(),
@@ -89,14 +87,11 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider
enabled,
supported: (server: Server) => !unsupported[server.key],
state: (server: Server, sessionID: string) => store[key(server, sessionID)],
attach(server: Server, sessionID: string, sessionKey: SessionStateKey) {
attach(server: Server, sessionID: string) {
const id = key(server, sessionID)
if (live.has(id)) return
const pane = platform.browserPane
if (!pane || !enabled() || unsupported[server.key] || server.health?.incompatible) return
// Focus requests write to the owning session's layout even while another shell tab is routed,
// so the Review pane and browser tab are already selected when the user returns to it.
const tabs = createRoot((dispose) => ({ dispose, layout: layout.tabs(sessionKey) }), owner)
const connection = createBrowserConnection({
pane,
// Resolve the current port at every wake, including after sidecar replacement.
@@ -105,15 +100,7 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider
sessionID,
endpoint: { ...server.conn.http, url: server.ctx.sdk.url },
}),
focus: (tabID) => {
const tab = sessionBrowserTab(tabID)
batch(() => {
shellTabs.setPane(findSessionTab(shellTabs.store, server.key, sessionID), "review", true)
if (!tabs.layout.all().includes(tab)) tabs.layout.setAll([...tabs.layout.all(), tab])
tabs.layout.setActive(tab)
})
},
preview: (path) => preview.get(id)?.forEach((listener) => listener(path)),
focus: (tabID) => focus.get(id)?.forEach((listener) => listener(tabID)),
change: (state) => {
if (state.error === "browser.pane.unsupported") {
setUnsupported(server.key, true)
@@ -151,18 +138,17 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider
entry.dispose = () => {
unsubscribe?.forEach((dispose) => dispose())
connection.dispose()
tabs.dispose()
}
},
/** Agent requests to show a file in this session's Review pane. */
onPreview(server: Server, sessionID: string, listener: (path: string) => void) {
/** Desktop focus requests for a mounted session route; nothing is replayed to routes mounted later. */
onFocus(server: Server, sessionID: string, listener: (tabID: Browser.TabID) => void) {
const id = key(server, sessionID)
const listeners = preview.get(id) ?? new Set()
const listeners = focus.get(id) ?? new Set()
listeners.add(listener)
preview.set(id, listeners)
focus.set(id, listeners)
return () => {
listeners.delete(listener)
if (!listeners.size) preview.delete(id)
if (!listeners.size) focus.delete(id)
}
},
command(server: Server, sessionID: string, command: BrowserPaneCommand) {
@@ -28,12 +28,10 @@ function fixture() {
commands: Browser.Action[]
}[] = []
const endpoint = { url: "http://localhost:4096" }
const previews: string[] = []
const connection = createBrowserConnection({
target: () => ({ serverKey: "browser-test", sessionID: "ses_browser", endpoint: { ...endpoint } }),
change: (state) => states.push(state),
focus: () => {},
preview: (path) => previews.push(path),
pane: {
register(target, emit) {
const call = { target, emit, closed: false, commands: [] as Browser.Action[] }
@@ -52,21 +50,9 @@ function fixture() {
})
connection.wake()
calls[0].emit({ type: "state", state: browser })
return { connection, calls, states, endpoint, previews }
return { connection, calls, states, endpoint }
}
test("preview requests reach the session without touching connection state", () => {
const app = fixture()
try {
const before = app.states.length
app.calls[0].emit({ type: "preview", path: "docs/report.pdf" })
expect(app.previews).toEqual(["docs/report.pdf"])
expect(app.states).toHaveLength(before)
} finally {
app.connection.dispose()
}
})
test("suspension retains tabs and reconnects once on demand using the current endpoint", async () => {
const app = fixture()
try {
@@ -19,7 +19,6 @@ export function createBrowserConnection(input: {
target: () => BrowserPaneTarget
change: (state: BrowserConnectionState) => void
focus: (tabID: Browser.TabID) => void
preview: (path: string) => void
}) {
const state: BrowserConnectionState = { browser: null, suspended: false }
let disposed = false
@@ -34,7 +33,6 @@ export function createBrowserConnection(input: {
(event) => {
if (disposed || state.registration !== registration) return
if (event.type === "focus") return input.focus(event.tabID)
if (event.type === "preview") return input.preview(event.path)
if (event.error === "browser.pane.unsupported" || event.error === "browser.pane.replaced") {
blocked = true
registration.close()
+10 -2
View File
@@ -1,4 +1,4 @@
import { batch, createEffect, createMemo, on } from "solid-js"
import { batch, createEffect, createMemo, on, onCleanup } from "solid-js"
import type { Browser } from "@opencode/plugin-browser/rpc"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
@@ -33,6 +33,13 @@ export function createSessionBrowser(session: SessionModel) {
attachment()?.browser?.tabs.filter((tab) => session.layout.tabs().all().includes(sessionBrowserTab(tab.id))) ??
[],
)
const focus = (tabID: Browser.TabID) => {
session.layout.view().reviewPanel.open()
const tabs = session.layout.tabs()
const key = sessionBrowserTab(tabID)
if (!tabs.all().includes(key)) tabs.setAll([...tabs.all(), key])
tabs.setActive(key)
}
const command = (command: BrowserPaneCommand) => {
const sessionID = session.identity.sessionID()
if (!sessionID) return
@@ -59,7 +66,8 @@ export function createSessionBrowser(session: SessionModel) {
createEffect(() => {
const sessionID = session.identity.sessionID()
if (!sessionID) return
if (attachments.enabled()) attachments.attach(server, sessionID, session.layout.sessionKey())
if (attachments.enabled()) attachments.attach(server, sessionID)
onCleanup(attachments.onFocus(server, sessionID, focus))
})
createEffect(
on(
+10 -63
View File
@@ -20,18 +20,13 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
const command = useCommand()
const state = props.browser.active
const address = () => (state()?.url === "about:blank" ? "" : (state()?.url ?? ""))
const failed = () => !!state()?.loadError
const registration = props.browser.registration
const button = { variant: "ghost", size: "large" } as const
const [store, setStore] = createStore({
address: "",
editing: false,
submitted: false,
// A submitted navigation the browser has not reported yet; keeps the empty state hidden meanwhile.
navigating: false,
visible: typeof document === "undefined" || document.visibilityState === "visible",
})
const empty = () => !address() && !state()?.loading && !store.navigating
let surface: HTMLDivElement | undefined
let addressDisplay: HTMLDivElement | undefined
let frame: number | undefined
@@ -48,7 +43,7 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
title: language.t("command.browser.reload"),
category: language.t("command.category.view"),
keybind: "f5",
disabled: !props.visible || !address(),
disabled: !props.visible || !state(),
onSelect: () => {
const tab = state()
if (tab) props.browser.command({ type: "reload", tabID: tab.id })
@@ -76,9 +71,7 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
const top = Math.round(rect.top * zoom)
const right = Math.round(rect.right * zoom)
const bottom = Math.round(rect.bottom * zoom)
// The desktop page hides blank and loading documents itself; only hide here
// while the pane shows its own empty or failed state over the surface.
const visible = props.visible && store.visible && !empty() && !failed() && !dialog.active && !covered(rect)
const visible = props.visible && store.visible && !dialog.active && !covered(rect)
// The cutout exposes the app backdrop outside the rounded Review card,
// not the browser surface inside it.
const color = getComputedStyle(
@@ -114,25 +107,7 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
if (frame === undefined) frame = requestAnimationFrame(tick)
}
createEffect(on([() => state()?.id, address], () => !store.editing && setStore("address", address())))
// Any reported movement, including a rejected or blocked request, ends the submitted navigation.
createEffect(
on(
[() => state()?.id, () => state()?.generation, () => state()?.loading, () => props.browser.error()],
() => setStore("navigating", false),
{ defer: true },
),
)
// A blocked or rejected submission leaves the page where it was; show that page's URL again.
createEffect(
on(
() => props.browser.error(),
(error) => {
if (error && !store.editing) setStore("address", address())
},
{ defer: true },
),
)
createEffect(() => !store.editing && setStore("address", address()))
createEffect(
on(registration, (current) => {
// Session routes can change before this pane unmounts. Hide the registration
@@ -148,8 +123,6 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
() => store.visible,
() => props.visible,
() => state()?.id,
empty,
failed,
registration,
],
() => {
@@ -184,7 +157,7 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
return (
<aside id="browser-panel" class="relative size-full min-w-0 overflow-hidden bg-v2-background-bg-base flex flex-col">
<div class="h-10 shrink-0 flex items-center gap-1 px-3 border-b border-v2-border-border-muted">
<div class="h-10 shrink-0 flex items-center gap-1 px-2 border-b border-v2-border-border-muted">
<For each={["back", "forward"] as const}>
{(direction) => (
<Tooltip placement="top" value={language.t(direction === "back" ? "common.goBack" : "common.goForward")}>
@@ -220,7 +193,7 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
>
<IconButton
{...button}
disabled={!state()?.loading && !address()}
disabled={!state()}
aria-label={language.t(state()?.loading ? "prompt.action.stop" : "error.page.action.reload")}
onClick={() => {
const tab = state()
@@ -239,13 +212,8 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
onSubmit={(event) => {
event.preventDefault()
const tab = state()
const url = store.address.trim()
if (!tab) return
if (url || failed()) {
setStore({ submitted: true, address: url, navigating: true })
props.browser.command({ type: "navigate", tabID: tab.id, url: url || "about:blank" })
}
event.currentTarget.querySelector("input")?.blur()
if (tab && store.address.trim())
props.browser.command({ type: "navigate", tabID: tab.id, url: store.address })
}}
>
<input
@@ -256,14 +224,8 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
disabled={!state()}
placeholder={language.t("session.browser.address.placeholder")}
aria-label={language.t("session.browser.address")}
onFocus={(event) => {
setStore("editing", true)
event.currentTarget.select()
}}
onClick={(event) => event.currentTarget.select()}
onBlur={() =>
setStore({ editing: false, address: store.submitted ? store.address : address(), submitted: false })
}
onFocus={() => setStore("editing", true)}
onBlur={() => setStore({ editing: false, address: address() })}
onInput={(event) => setStore("address", event.currentTarget.value)}
onScroll={(event) => {
if (addressDisplay) addressDisplay.scrollLeft = event.currentTarget.scrollLeft
@@ -281,27 +243,12 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
</div>
</form>
</div>
<Show when={props.browser.error() && !failed()}>
<Show when={props.browser.error()}>
<div class="shrink-0 px-3 py-1.5 text-12-regular text-text-danger-base border-b border-v2-border-border-muted">
{props.browser.error()}
</div>
</Show>
<div ref={surface} class="min-h-0 flex-1 bg-v2-background-bg-base flex items-center justify-center">
<Show when={(empty() || failed()) && !props.browser.suspended()}>
{/* Add the 40px toolbar to the file empty state's 160px bottom padding to align their centers. */}
<div
dir="auto"
class="flex size-full flex-col items-center justify-center gap-2 p-6 pb-[200px] text-center text-text-weak"
>
<Icon name="globe" size="large" class="mb-2 shrink-0" />
<div class="text-[13px] font-medium leading-[var(--line-height-compact)] text-text-strong">
{language.t(failed() ? "session.browser.failed.title" : "session.browser.empty.title")}
</div>
<div class="text-13-regular leading-[var(--line-height-base)]">
{language.t(failed() ? "session.browser.failed.description" : "session.browser.empty.description")}
</div>
</div>
</Show>
<Show when={props.browser.suspended()}>
<p class="px-6 text-center text-13-regular text-v2-text-text-subtle" role="status">
{language.t("session.browser.suspended")}
@@ -1,94 +0,0 @@
/* The control defaults to a fixed 232px with equal segments; here it hugs its two labels. */
[data-slot="artifact-toolbar"] [data-slot="segmented-control-v2"] {
width: auto;
}
[data-slot="artifact-toolbar"] [data-slot="segmented-control-v2-item"] {
flex: 0 0 auto;
padding: 0 14px;
}
/* Shrink-wrapped segments round the label box down a fraction; the ellipsis overflow then clips glyph edges. */
[data-slot="artifact-toolbar"] [data-slot="segmented-control-v2-item-label"] {
overflow: visible;
max-width: none;
}
[data-slot="artifact-stage"] {
--artifact-check: color-mix(in oklch, var(--v2-text-text-base) 5%, transparent);
background-color: var(--v2-background-bg-deep);
}
[data-slot="artifact-stage"][data-checker] {
background-image:
linear-gradient(45deg, var(--artifact-check) 25%, transparent 25%),
linear-gradient(-45deg, var(--artifact-check) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--artifact-check) 75%),
linear-gradient(-45deg, transparent 75%, var(--artifact-check) 75%);
background-size: 16px 16px;
background-position:
0 0,
0 8px,
8px -8px,
-8px 0;
}
[data-slot="artifact-media"] {
display: block;
border-radius: 4px;
box-shadow:
0 0 0 0.5px var(--v2-border-border-base),
0 12px 32px -12px color-mix(in oklch, var(--v2-text-text-base) 25%, transparent);
}
[data-slot="artifact-stage"][data-zoom="fit"] [data-slot="artifact-media"] {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
[data-slot="artifact-stage"][data-zoom="fit"][data-overflow] [data-slot="artifact-media"] {
cursor: zoom-in;
}
[data-slot="artifact-stage"][data-zoom="actual"] [data-slot="artifact-media"] {
cursor: zoom-out;
}
[data-slot="artifact-table"] {
border-collapse: separate;
border-spacing: 0;
font-variant-numeric: tabular-nums;
}
[data-slot="artifact-table"] th,
[data-slot="artifact-table"] td {
padding: 5px 12px;
border-bottom: 1px solid var(--v2-border-border-muted);
border-inline-end: 1px solid var(--v2-border-border-muted);
white-space: pre;
max-width: 40ch;
overflow: hidden;
text-overflow: ellipsis;
text-align: start;
line-height: var(--line-height-compact);
}
[data-slot="artifact-table"] th {
position: sticky;
top: 0;
z-index: 1;
background: var(--v2-background-bg-layer-01);
font-weight: var(--font-weight-medium);
color: var(--v2-text-text-muted);
}
[data-slot="artifact-table"] tbody tr:hover td {
background: color-mix(in oklch, var(--v2-text-text-base) 3%, transparent);
}
[data-slot="artifact-table"] td[data-index] {
color: var(--v2-text-text-faint);
text-align: end;
user-select: none;
}
@@ -1,454 +0,0 @@
import { createEffect, createMemo, For, Match, on, onCleanup, Show, Switch, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Button } from "@opencode/ui/button"
import { FileIcon } from "@opencode/ui/file-icon"
import { SegmentedControl, SegmentedControlItem } from "@opencode/ui/segmented-control"
import { ScrollView } from "@opencode/ui/scroll-view"
import { Markdown } from "@opencode/session-ui/markdown"
import { MarkdownProvider, useMarkdown } from "@opencode/session-ui/context/markdown"
import { getDirectory, getFilename } from "@opencode/util/path"
import type { FileContent } from "@/runtime/server/types"
import { useLanguage } from "@/runtime/i18n/language"
import {
artifactKind,
blobUrlFromContent,
contentBytes,
parseDelimited,
resolveArtifactPath,
type ArtifactKind,
} from "@/workspaces/files/artifact"
import { useArtifactOpener } from "@/session/files/open-artifact"
import "./artifact-view.css"
type ArtifactMode = "preview" | "source"
/** Facts a viewer learns from the decoded media, shown in the toolbar. */
type ArtifactInfo = { width?: number; height?: number; duration?: number; rows?: number; columns?: number }
type MediaProps = {
path: string
content: FileContent
onInfo: (info: ArtifactInfo) => void
/** The browser could not decode the bytes; the host falls back to the binary placeholder. */
onError: () => void
}
/** Kinds that render a preview from their text and can toggle back to highlighted source. */
const previewableKinds = new Set<ArtifactKind>(["svg", "html", "markdown", "mermaid", "table"])
/**
* Renders a loaded non-text file: media, documents, and data get a dedicated viewer with a toolbar;
* previewable text kinds can switch to `source`, which the host supplies (its code view).
*/
export function ArtifactView(props: { path: string; content: FileContent; cacheKey?: string; source: JSX.Element }) {
const language = useLanguage()
const [state, setState] = createStore({
mode: "preview" as ArtifactMode,
info: {} as ArtifactInfo,
// Media the browser could not decode falls back to the binary placeholder.
undecodable: false,
})
createEffect(
on(
() => props.content,
() => setState({ mode: "preview", info: {}, undecodable: false }),
{ defer: true },
),
)
const kind = createMemo<ArtifactKind | "binary">(() => {
if (props.content.type === "binary" && !props.content.mimeType) return "binary"
if (state.undecodable) return "binary"
return artifactKind(props.path)
})
const previewable = createMemo(() => {
const value = kind()
return value !== "binary" && previewableKinds.has(value)
})
const meta = createMemo(() => {
const info = state.info
return [
info.width && info.height ? `${info.width} × ${info.height}` : undefined,
info.duration ? formatDuration(info.duration) : undefined,
info.rows !== undefined ? language.plural("file.view.table.rows", Math.max(0, info.rows - 1)) : undefined,
info.columns !== undefined ? language.plural("file.view.table.columns", info.columns) : undefined,
formatBytes(language.intl(), contentBytes(props.content)),
].filter((item): item is string => !!item)
})
const media = { onInfo: (info: ArtifactInfo) => setState("info", info), onError: () => setState("undecodable", true) }
const rendered = () => (
<ScrollView class="min-h-0 flex-1">
<Show
when={kind() === "markdown"}
fallback={<ArtifactMermaid text={props.content.content} cacheKey={props.cacheKey} />}
>
<ArtifactMarkdown path={props.path} text={props.content.content} cacheKey={props.cacheKey} />
</Show>
</ScrollView>
)
return (
<>
<ArtifactToolbar
mode={state.mode}
onModeChange={previewable() ? (mode) => setState("mode", mode) : undefined}
meta={meta()}
actions={
<Show when={kind() === "html"}>
<OpenInBrowserButton path={props.path} />
</Show>
}
/>
<Show when={!previewable() || state.mode === "preview"} fallback={props.source}>
<Switch>
<Match when={kind() === "image" || kind() === "svg"}>
<ArtifactImage path={props.path} content={props.content} {...media} />
</Match>
<Match when={kind() === "video"}>
<ArtifactVideo path={props.path} content={props.content} {...media} />
</Match>
<Match when={kind() === "audio"}>
<ArtifactAudio path={props.path} content={props.content} {...media} />
</Match>
<Match when={kind() === "pdf" || kind() === "html"}>
<ArtifactFrame path={props.path} content={props.content} kind={kind() === "pdf" ? "pdf" : "html"} />
</Match>
<Match when={kind() === "font"}>
<ArtifactFont path={props.path} content={props.content} />
</Match>
<Match when={kind() === "table"}>
<ArtifactTable path={props.path} text={props.content.content} onInfo={media.onInfo} />
</Match>
<Match when={kind() === "markdown" || kind() === "mermaid"}>{rendered()}</Match>
<Match when={kind() === "binary"}>
<ArtifactBinary path={props.path} size={formatBytes(language.intl(), contentBytes(props.content))} />
</Match>
</Switch>
</Show>
</>
)
}
function formatBytes(locale: string, bytes: number) {
const units = ["byte", "kilobyte", "megabyte", "gigabyte"] as const
const index = Math.min(units.length - 1, bytes > 0 ? Math.floor(Math.log10(bytes) / 3) : 0)
const value = bytes / 1000 ** index
return new Intl.NumberFormat(locale, {
style: "unit",
unit: units[index],
// "short" bytes render as the singular "byte"; the long form pluralizes correctly.
unitDisplay: index === 0 ? "long" : "short",
maximumFractionDigits: value >= 100 || index === 0 ? 0 : 1,
}).format(value)
}
function formatDuration(seconds: number) {
const total = Math.round(seconds)
const minutes = Math.floor(total / 60)
return `${minutes}:${String(total % 60).padStart(2, "0")}`
}
function ArtifactToolbar(props: {
mode?: ArtifactMode
onModeChange?: (mode: ArtifactMode) => void
meta: string[]
actions?: JSX.Element
}) {
const language = useLanguage()
return (
<div data-slot="artifact-toolbar" class="flex h-10 shrink-0 items-center gap-3 px-4">
<Show when={props.onModeChange}>
<SegmentedControl
value={props.mode ?? "preview"}
onChange={(value) => {
if (value === "preview" || value === "source") props.onModeChange?.(value)
}}
>
<SegmentedControlItem value="preview">{language.t("file.view.preview")}</SegmentedControlItem>
<SegmentedControlItem value="source">{language.t("file.view.source")}</SegmentedControlItem>
</SegmentedControl>
</Show>
<div class="ms-auto flex min-w-0 items-center gap-3">
<div class="flex min-w-0 items-center gap-2 text-12-regular text-text-weak">
<For each={props.meta}>
{(item, index) => (
<>
<Show when={index() > 0}>
<span aria-hidden class="text-text-faint">
·
</span>
</Show>
<span class="truncate tabular-nums">{item}</span>
</>
)}
</For>
</div>
{props.actions}
</div>
</div>
)
}
function OpenInBrowserButton(props: { path: string }) {
const language = useLanguage()
const artifacts = useArtifactOpener()
return (
<Show when={artifacts.canOpenInBrowser(props.path)}>
<Button size="small" variant="ghost" icon="globe" onClick={() => artifacts.openInBrowser(props.path)}>
{language.t("file.view.openInBrowser")}
</Button>
</Show>
)
}
function createBlobUrl(content: () => FileContent) {
return createMemo(() => {
const value = blobUrlFromContent(content())
onCleanup(() => URL.revokeObjectURL(value))
return value
})
}
/** Images and SVG previews: fit the pane, click to inspect at 1:1 when the image is larger. */
function ArtifactImage(props: MediaProps) {
const url = createBlobUrl(() => props.content)
const [state, setState] = createStore({ zoom: "fit" as "fit" | "actual", overflow: false, width: 0, height: 0 })
let stage: HTMLDivElement | undefined
const measure = () => {
if (!stage) return
setState("overflow", state.width > stage.clientWidth - 48 || state.height > stage.clientHeight - 48)
}
createResizeObserver(
() => stage,
() => measure(),
)
createEffect(() => {
url()
setState({ zoom: "fit", overflow: false })
})
return (
<div
ref={stage}
data-slot="artifact-stage"
data-checker
data-zoom={state.zoom}
data-overflow={state.overflow || undefined}
class="relative min-h-0 flex-1 overflow-auto"
>
<div
classList={{
"absolute inset-0 flex items-center justify-center p-6": state.zoom === "fit",
"flex min-h-full min-w-full w-max items-center justify-center p-6": state.zoom === "actual",
}}
>
<img
data-slot="artifact-media"
src={url()}
alt={getFilename(props.path)}
draggable={false}
onError={() => props.onError()}
onLoad={(event) => {
const image = event.currentTarget
setState({ width: image.naturalWidth, height: image.naturalHeight })
props.onInfo({ width: image.naturalWidth, height: image.naturalHeight })
measure()
}}
onClick={() => {
if (!state.overflow && state.zoom === "fit") return
setState("zoom", state.zoom === "fit" ? "actual" : "fit")
}}
/>
</div>
</div>
)
}
function ArtifactVideo(props: MediaProps) {
const url = createBlobUrl(() => props.content)
return (
<div data-slot="artifact-stage" data-zoom="fit" class="relative min-h-0 flex-1 overflow-hidden">
<div class="absolute inset-0 flex items-center justify-center p-6">
<video
data-slot="artifact-media"
class="w-full bg-black"
controls
preload="metadata"
playsinline
onError={() => props.onError()}
src={url()}
onLoadedMetadata={(event) => {
const video = event.currentTarget
props.onInfo({ width: video.videoWidth, height: video.videoHeight, duration: video.duration })
}}
/>
</div>
</div>
)
}
function ArtifactAudio(props: MediaProps) {
const url = createBlobUrl(() => props.content)
return (
<div data-slot="artifact-stage" class="relative min-h-0 flex-1 overflow-auto">
<div class="absolute inset-0 flex items-center justify-center p-6">
<div class="flex w-full max-w-lg flex-col items-center gap-5 rounded-xl border border-v2-border-border-muted bg-v2-background-bg-base px-8 py-8 shadow-[var(--v2-elevation-raised)]">
<div class="flex size-14 items-center justify-center rounded-full bg-v2-background-bg-layer-02">
<FileIcon node={{ path: props.path, type: "file" }} class="size-7" />
</div>
<div class="max-w-full truncate text-14-medium text-text-strong">{getFilename(props.path)}</div>
<audio
class="w-full"
onError={() => props.onError()}
controls
preload="metadata"
src={url()}
onLoadedMetadata={(event) => props.onInfo({ duration: event.currentTarget.duration })}
/>
</div>
</div>
</div>
)
}
function ArtifactFrame(props: { path: string; content: FileContent; kind: "pdf" | "html" }) {
const url = createBlobUrl(() => props.content)
// PDF Open Parameters: start with the thumbnail pane closed and the page fitted to the pane width.
const src = () => (props.kind === "pdf" ? `${url()}#navpanes=0&view=FitH` : url())
return (
<iframe
class="block h-full w-full flex-1 border-0 bg-white"
title={getFilename(props.path)}
src={src()}
// The PDF viewer is Chromium's own and does not run in a sandboxed frame. HTML runs as an
// opaque origin: no app storage, cookies, or credentialed requests reach it.
sandbox={props.kind === "html" ? "allow-scripts allow-popups allow-forms allow-modals" : undefined}
referrerPolicy="no-referrer"
/>
)
}
function ArtifactMarkdown(props: { path: string; text: string; cacheKey?: string }) {
const parent = useMarkdown()
const artifacts = useArtifactOpener()
// getDirectory yields "/" for a root-level file, which would make relative links absolute.
const dir = createMemo(() => (props.path.includes("/") || props.path.includes("\\") ? getDirectory(props.path) : ""))
// Absolute references bypass the file's directory; relative ones resolve against it.
const resolve = (href: string) => (/^([a-z]:)?\//i.test(href) ? href : (resolveArtifactPath(dir(), href) ?? href))
return (
<MarkdownProvider
readImage={(src, signal) => parent?.readImage?.(resolve(src), signal) ?? Promise.resolve(undefined)}
openLocalFile={(href) => artifacts.open(href, dir())}
>
<div class="mx-auto w-full max-w-3xl px-8 py-6">
<Markdown text={props.text} cacheKey={props.cacheKey} class="select-text" />
</div>
</MarkdownProvider>
)
}
/** Mermaid sources render through the same fenced-block pipeline the timeline uses. */
function ArtifactMermaid(props: { text: string; cacheKey?: string }) {
return (
<div class="mx-auto w-full max-w-4xl px-8 py-6">
<Markdown text={`\`\`\`mermaid\n${props.text}\n\`\`\``} cacheKey={props.cacheKey} class="select-text" />
</div>
)
}
function ArtifactTable(props: { path: string; text: string; onInfo: (info: ArtifactInfo) => void }) {
const language = useLanguage()
const parsed = createMemo(() => parseDelimited(props.text, props.path.toLowerCase().endsWith(".tsv") ? "\t" : ","))
createEffect(() => props.onInfo({ rows: parsed().total, columns: parsed().columns }))
// Pad the header to the widest row so no data column is dropped.
const header = () => Array.from({ length: parsed().columns }, (_, index) => parsed().rows[0]?.[index] ?? "")
const body = () => parsed().rows.slice(1)
return (
<div class="min-h-0 flex-1 overflow-auto">
<table data-slot="artifact-table" class="min-w-full text-13-regular text-text-base">
<thead>
<tr>
<th data-index />
<For each={header()}>{(cell) => <th>{cell}</th>}</For>
</tr>
</thead>
<tbody>
<For each={body()}>
{(row, index) => (
<tr>
<td data-index>{index() + 1}</td>
<For each={header()}>{(_, column) => <td>{row[column()] ?? ""}</td>}</For>
</tr>
)}
</For>
</tbody>
</table>
<Show when={parsed().total > parsed().rows.length}>
<div class="px-4 py-3 text-12-regular text-text-weak">
{language.t("file.view.table.truncated", { shown: parsed().rows.length - 1, total: parsed().total - 1 })}
</div>
</Show>
</div>
)
}
const specimenSizes = [12, 16, 24, 40, 64]
function ArtifactFont(props: { path: string; content: FileContent }) {
const language = useLanguage()
const url = createBlobUrl(() => props.content)
const family = createMemo(() => `artifact-${Math.random().toString(36).slice(2)}`)
createEffect(() => {
const face = new FontFace(family(), `url(${url()})`)
document.fonts.add(face)
void face.load().catch(() => undefined)
onCleanup(() => document.fonts.delete(face))
})
return (
<div class="min-h-0 flex-1 overflow-auto">
<div class="mx-auto flex w-full max-w-3xl flex-col gap-6 px-8 py-8" style={{ "font-family": `"${family()}"` }}>
<div class="text-text-strong" style={{ "font-size": "56px", "line-height": "1.1" }}>
{getFilename(props.path).replace(/\.[^.]+$/, "")}
</div>
<div class="break-all text-text-base" style={{ "font-size": "22px", "line-height": "1.4" }}>
ABCDEFGHIJKLMNOPQRSTUVWXYZ
<br />
abcdefghijklmnopqrstuvwxyz
<br />
0123456789 !?&@#%(){}[]
</div>
<div class="flex flex-col gap-3 border-t border-v2-border-border-muted pt-6">
<For each={specimenSizes}>
{(size) => (
<div class="flex items-baseline gap-4">
<span
class="w-8 shrink-0 text-12-regular text-text-faint tabular-nums"
style={{ "font-family": "var(--font-family-mono)" }}
>
{size}
</span>
<span class="text-text-base" style={{ "font-size": `${size}px`, "line-height": "1.25" }}>
{language.t("file.view.fontSample")}
</span>
</div>
)}
</For>
</div>
</div>
</div>
)
}
function ArtifactBinary(props: { path: string; size: string }) {
const language = useLanguage()
return (
<div data-slot="artifact-stage" class="relative min-h-0 flex-1">
<div class="absolute inset-0 flex flex-col items-center justify-center gap-3 p-6 text-center">
<FileIcon node={{ path: props.path, type: "file" }} class="size-8 text-text-weak" />
<div class="text-14-medium text-text-strong">{getFilename(props.path)}</div>
<div class="text-13-regular text-text-weak">{language.t("file.view.binary", { size: props.size })}</div>
</div>
</div>
)
}
+30 -39
View File
@@ -1,4 +1,4 @@
import { createEffect, createMemo, createSignal, Match, on, onCleanup, Show, Switch } from "solid-js"
import { createEffect, createMemo, createSignal, Match, on, onCleanup, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import { makeEventListener } from "@solid-primitives/event-listener"
@@ -11,9 +11,8 @@ import { LineCommentOverflowIcon } from "@opencode/ui/line-comment"
import { Menu } from "@opencode/ui/menu"
import { Tabs } from "@opencode/ui/tabs"
import { ScrollView } from "@opencode/ui/scroll-view"
import { showToast } from "@/shell/notifications/toast"
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/workspaces/files/model"
import { artifactKind } from "@/workspaces/files/artifact"
import { ArtifactView } from "@/session/files/artifact-view"
import { useComments } from "@/composer/comments"
import { useLanguage } from "@/runtime/i18n/language"
import { useComposerState } from "@/composer/persistence"
@@ -206,11 +205,6 @@ export function SessionFileView(props: SessionFileViewProps) {
})
const contents = createMemo(() => state()?.content?.content ?? "")
const cacheKey = createMemo(() => sampledChecksum(contents()))
// Plain text keeps the code view; every other kind is rendered by ArtifactView.
const artifact = createMemo(() => {
const content = state()?.content
return content?.type === "binary" || artifactKind(path() ?? "") !== "text"
})
const selectedLines = createMemo<SelectedLineRange | null>(() => {
const p = path()
if (!p) return null
@@ -439,42 +433,39 @@ export function SessionFileView(props: SessionFileViewProps) {
}}
search={search}
class="select-text"
// Media and previews have their own viewers below; the code view only ever shows text.
media={{ mode: "off" }}
media={{
mode: "auto",
path: path(),
current: state()?.content,
onLoad: scrollSync.queueRestore,
onError: (args: { kind: "image" | "audio" | "svg" }) => {
if (args.kind !== "svg") return
showToast({
variant: "error",
title: language.t("toast.file.loadFailed.title"),
})
},
}}
/>
</div>
)
// The code view scrolls inside ScrollView so line state and scroll position persist per tab.
const codeView = (source: string) => (
<ScrollView class="min-h-0 flex-1" viewportRef={scrollSync.setViewport} onScroll={scrollSync.handleScroll}>
{renderFile(source)}
</ScrollView>
)
const content = () => (
<div class="mt-3 relative h-full min-h-0 flex flex-col">
<Switch>
<Match when={state()?.loaded ? state()?.content : undefined}>
{(value) => (
<Show when={artifact()} fallback={codeView(value().content)}>
<ArtifactView
path={path() ?? ""}
content={value()}
cacheKey={cacheKey()}
source={codeView(value().content)}
/>
</Show>
)}
</Match>
<Match when={state()?.loading}>
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}</div>
</Match>
<Match when={state()?.notFound ? state()?.name : undefined}>
{(name) => <div class="px-6 py-4 text-text-weak">{language.t("file.error.notFound", { name: name() })}</div>}
</Match>
<Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
</Switch>
<div class="mt-3 relative h-full min-h-0">
<ScrollView class="h-full" viewportRef={scrollSync.setViewport} onScroll={scrollSync.handleScroll}>
<Switch>
<Match when={state()?.loaded}>{renderFile(contents())}</Match>
<Match when={state()?.loading}>
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}</div>
</Match>
<Match when={state()?.notFound ? state()?.name : undefined}>
{(name) => (
<div class="px-6 py-4 text-text-weak">{language.t("file.error.notFound", { name: name() })}</div>
)}
</Match>
<Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
</Switch>
</ScrollView>
</div>
)
@@ -1,105 +0,0 @@
import { createEffect, onCleanup, type ParentProps } from "solid-js"
import { createSimpleContext } from "@opencode/ui/context"
import { MarkdownProvider, useMarkdown } from "@opencode/session-ui/context/markdown"
import { useBrowserAttachments } from "@/session/browser/attachments"
import type { SessionModel } from "@/session/model"
import { useFile } from "@/workspaces/files/model"
import { artifactKind, resolveArtifactPath } from "@/workspaces/files/artifact"
import { encodeFilePath } from "@/workspaces/files/path"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServer } from "@/runtime/server/current"
import { ServerConnection } from "@/runtime/server/registry"
import { useSessionLayout } from "@/session/session-layout"
import { createOpenSessionFileTab } from "@/session/helpers"
import type { createSessionBrowser } from "@/session/browser/model"
/** Routes local links in timeline markdown to the artifact opener while keeping image loading. */
export function ArtifactMarkdownProvider(props: ParentProps) {
const markdown = useMarkdown()
const artifacts = useArtifactOpener()
return (
<MarkdownProvider readImage={markdown?.readImage} openLocalFile={(path) => artifacts.open(path)}>
{props.children}
</MarkdownProvider>
)
}
/**
* Opens files the agent references as side-panel tabs, inside or outside the workspace, or as
* a browser tab for HTML when the desktop can load the file directly.
*/
export const { use: useArtifactOpener, provider: ArtifactOpenerProvider } = createSimpleContext({
name: "ArtifactOpener",
init: (props: { session: SessionModel; browser: ReturnType<typeof createSessionBrowser> }) => {
const file = useFile()
const server = useServer()
const location = useWorkspaceLocation()
const attachments = useBrowserAttachments()
const { tabs, view } = useSessionLayout()
const root = () => location().directory.replaceAll("\\", "/").replace(/\/+$/, "")
/**
* Turn a link into a path `useFile` can load: workspace-relative when it is under the root,
* otherwise absolute. Relative links resolve against `base`; ones that climb past the root
* become absolute too, so a `../../shared/report.pdf` still opens.
*/
const resolve = (href: string, base?: string) => {
// Agents cite locations as path:line or path:line:col; the file is what opens.
const value = href.replaceAll("\\", "/").replace(/:\d+(?::\d+)?$/, "")
if (/^[a-z]:\//i.test(value) || value.startsWith("/")) return file.normalize(value)
const relative = resolveArtifactPath(base ?? "", value)
if (relative !== undefined) return file.normalize(relative)
// Climbing past the workspace root: resolve from the referencing folder's absolute location.
const dir = base ? `${root()}/${base.replace(/\/+$/, "")}` : root()
return file.normalize(resolveArtifactPath(dir, value) ?? value)
}
const showTab = createOpenSessionFileTab({
normalizeTab: (tab) => tab,
openTab: (tab) => tabs().open(tab),
pathFromTab: file.pathFromTab,
loadFile: () => undefined,
openReviewPanel: () => {
if (!view().reviewPanel.opened()) view().reviewPanel.open()
},
setActive: (tab) => tabs().setActive(tab),
})
// Inline paths are guessed from text, so confirm the file exists before a tab appears for it.
const openTab = (path: string) => {
void file.load(path).then(() => {
if (file.get(path)?.loaded) showTab(file.tab(path))
})
}
// The desktop's own sidecar shares this disk, and its browser pane accepts file:// URLs inside the
// session workspace only. Forwarded loopback servers do not qualify, matching the desktop policy.
const canOpenInBrowser = (path?: string) =>
ServerConnection.builtin(server.conn) &&
props.browser.available() &&
props.browser.attached() &&
(path === undefined || !file.absolute(path))
const openInBrowser = (path: string) => {
props.browser.command({ type: "tabs.open", url: `file://${encodeFilePath(`${root()}/${path}`)}` })
}
/** Open `href` as referenced from `base` (a workspace-relative directory, "" for the root). */
const open = (href: string, base?: string) => {
const path = resolve(href, base)
if (!path) return
if (artifactKind(path) === "html" && canOpenInBrowser(path)) return openInBrowser(path)
openTab(path)
}
// The agent's browser.preview tool arrives through the desktop browser pane attachment.
createEffect(() => {
const sessionID = props.session.identity.sessionID()
if (!sessionID) return
onCleanup(attachments.onPreview(server, sessionID, (path) => open(path)))
})
return { canOpenInBrowser, openInBrowser, open }
},
})
@@ -173,9 +173,7 @@ export function SessionFileBrowserTab(props: {
<SessionFilePanelV2Empty>
<div class="flex flex-col items-center gap-2 text-center text-text-weak">
<Icon name="file-tree" size="large" class="mb-2" />
<div class="text-[13px] font-medium leading-[13px] text-text-strong">
{language.t("command.file.open")}
</div>
<div class="text-[13px] font-medium leading-[13px] text-text-strong">{language.t("command.file.open")}</div>
<div class="h-5 text-13-regular leading-5">{language.t("session.files.selectToOpen")}</div>
</div>
</SessionFilePanelV2Empty>
+3 -15
View File
@@ -1,8 +1,6 @@
import { children, createMemo, Show } from "solid-js"
import type { JSX } from "solid-js"
import { useSortable } from "@dnd-kit/solid/sortable"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { Keybind } from "@opencode/ui/keybind"
import { Tooltip } from "@opencode/ui/tooltip"
import { Tabs } from "@opencode/ui/tabs"
@@ -71,19 +69,9 @@ export function SortableTab(props: {
placement="bottom"
gutter={10}
>
<IconButton
size="small"
variant="ghost-muted"
onPointerDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
props.onTabClose(props.tab)
}}
icon={<Icon name="xmark-small" />}
<Tabs.CloseButton
class="h-5 w-5"
onClick={() => props.onTabClose(props.tab)}
aria-label={language.t("common.closeTab")}
/>
</Tooltip>
-1
View File
@@ -137,7 +137,6 @@ export function useSessionModel() {
tabs: layout.tabs,
view: layout.view,
tabKey: layout.tabKey,
sessionKey: layout.sessionKey,
},
ownership: createSessionOwnership(layout.sessionKey),
tabs: {
-3
View File
@@ -155,9 +155,6 @@ export function createSessionReview(input: {
const count = () => diffs().length
const hasChanges = () => count() > 0
const ready = () => {
// A project without VCS never enables vcsQuery, so its status stays "pending" forever.
const project = input.session.project()
if (project && !project.vcs) return true
if (mode() === "git" || mode() === "branch") return !vcsQuery.isPending
return true
}
+2 -2
View File
@@ -249,7 +249,7 @@ function ReviewTitle(props: { review: SessionReviewModel }) {
function ReviewEmpty(props: { review: SessionReviewModel; loadingClass: string }) {
const language = useLanguage()
const loading = () => (props.review.mode() === "git" || props.review.mode() === "branch") && !props.review.ready()
const noGit = () => props.review.noGit()
const noGit = () => props.review.mode() === "turn" && props.review.noGit()
const text = () => {
if (props.review.mode() === "git") return language.t("session.review.noUncommittedChanges")
if (props.review.mode() === "branch") return language.t("session.review.noBranchChanges")
@@ -282,7 +282,7 @@ function ReviewEmpty(props: { review: SessionReviewModel; loadingClass: string }
function ReviewPanelEmpty(props: { review: SessionReviewModel }) {
const language = useLanguage()
const loading = () => (props.review.mode() === "git" || props.review.mode() === "branch") && !props.review.ready()
const noGit = () => props.review.noGit()
const noGit = () => props.review.mode() === "turn" && props.review.noGit()
return (
<Switch>
<Match when={loading()}>
+1 -14
View File
@@ -38,7 +38,6 @@ import { SessionReviewToggle } from "./header/session-header-actions"
import { createAnimatedPresence } from "@/runtime/animated-presence"
import { createSessionBrowser } from "./browser/model"
import { createTimelineCache } from "./timeline/cache"
import { ArtifactMarkdownProvider, ArtifactOpenerProvider } from "./files/open-artifact"
const SessionMobileFiles = lazy(async () => {
const { SessionMobileFiles } = await import("./files/session-mobile-files")
@@ -51,26 +50,14 @@ const SessionSummaryPanel = lazy(async () => {
})
export function SessionScreen(props: { session: SessionModel }) {
// The timeline cache captures its owner when created, so link handling must be provided above it.
const browser = createSessionBrowser(props.session)
return (
<ArtifactOpenerProvider session={props.session} browser={browser}>
<ArtifactMarkdownProvider>
<SessionScreenContent session={props.session} browser={browser} />
</ArtifactMarkdownProvider>
</ArtifactOpenerProvider>
)
}
function SessionScreenContent(props: { session: SessionModel; browser: ReturnType<typeof createSessionBrowser> }) {
const session = props.session
const browser = props.browser
const server = useServer()
const detailsProject = createMemo(() => {
const info = session.data.info()
return info ? projectForSession(info, server.ctx.sync.data.project) : undefined
})
const isDesktop = session.isDesktop
const browser = createSessionBrowser(session)
const screen = createSessionScreenLayout(session)
const timeline = createSessionTimelineInteraction(session)
const timelineSearch = createTimelineSearchController({
+4
View File
@@ -1102,6 +1102,10 @@
padding-block: 20px;
}
.settings-server-connection .settings-servers-lead {
gap: 4px;
}
.settings-servers-row:not(:last-child) {
padding-bottom: 16px;
margin-bottom: 16px;
+1 -10
View File
@@ -9,8 +9,7 @@
justify-content: center;
}
[data-titlebar-tab],
[data-titlebar-tab-action] {
[data-titlebar-tab] {
--tab-base: var(--v2-background-bg-deep);
--tab-overlay: transparent;
background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base);
@@ -69,14 +68,6 @@
--tab-overlay: var(--v2-overlay-simple-overlay-pressed);
}
[data-titlebar-tab-action]:is(:hover, :focus-visible) {
--tab-overlay: var(--v2-overlay-simple-overlay-hover);
}
[data-titlebar-tab-action]:is(:active, [data-state="pressed"]) {
--tab-overlay: var(--v2-overlay-simple-overlay-pressed);
}
[data-titlebar-tab]:is(:hover, [data-state="pressed"]) [data-slot="tab-close"] {
background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base);
}
+2 -4
View File
@@ -351,10 +351,9 @@ export function Titlebar(props: {
>
<button
type="button"
data-titlebar-tab-action
data-action="vertical-tabs-home"
data-state={layout.route().type === "home" ? "pressed" : undefined}
class="group mb-1 flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:text-v2-text-text-base data-[state=pressed]:text-v2-text-text-base"
class="group mb-1 flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base data-[state=pressed]:bg-v2-background-bg-layer-02 data-[state=pressed]:text-v2-text-text-base"
onClick={toggleHome}
aria-label={language.t("home.title")}
aria-pressed={layout.route().type === "home"}
@@ -651,9 +650,8 @@ export function Titlebar(props: {
{homeButton(true)}
<button
type="button"
data-titlebar-tab-action
data-action="vertical-tabs-new-session"
class="group flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:text-v2-text-text-base"
class="group flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base"
onClick={openNewTab}
aria-label={language.t("command.session.new")}
>
@@ -1,118 +0,0 @@
import { describe, expect, test } from "bun:test"
import {
artifactKind,
bytesToBase64,
contentBytes,
fileContentFromBytes,
MAX_MEDIA_BYTES,
parseDelimited,
resolveArtifactPath,
} from "./artifact"
describe("artifactKind", () => {
test.each([
["shot.PNG", "image"],
["logo.svg", "svg"],
["song.mp3", "audio"],
["demo.mp4", "video"],
["clip.webm", "video"],
["paper.pdf", "pdf"],
["out/index.html", "html"],
["README.md", "markdown"],
["flow.mmd", "mermaid"],
["data.csv", "table"],
["data.tsv", "table"],
["Inter.woff2", "font"],
["src/app.ts", "text"],
["Makefile", "text"],
[".env", "text"],
["archive.tar.gz", "text"],
] as const)("classifies %s as %s", (path, kind) => {
expect(artifactKind(path)).toBe(kind)
})
})
describe("fileContentFromBytes", () => {
test("keeps media as base64 with a mime type", () => {
const content = fileContentFromBytes("a.png", new Uint8Array([137, 80, 78, 71]))
expect(content).toEqual({ type: "binary", content: "iVBORw==", encoding: "base64", mimeType: "image/png" })
})
test("decodes text and svg with a mime type", () => {
expect(fileContentFromBytes("a.svg", new TextEncoder().encode("<svg/>"))).toEqual({
type: "text",
content: "<svg/>",
mimeType: "image/svg+xml",
})
expect(fileContentFromBytes("a.ts", new TextEncoder().encode("const a = 1"))).toEqual({
type: "text",
content: "const a = 1",
mimeType: undefined,
})
})
test("keeps only the size of media above the cap", () => {
const content = fileContentFromBytes("big.mp4", new Uint8Array(MAX_MEDIA_BYTES + 1))
expect(content).toEqual({ type: "binary", content: "", size: MAX_MEDIA_BYTES + 1 })
})
test("marks unknown binaries without keeping bytes", () => {
expect(fileContentFromBytes("a.bin", new Uint8Array([1, 0, 2]))).toEqual({ type: "binary", content: "", size: 3 })
})
test("encodes large buffers in chunks", () => {
const bytes = new Uint8Array(70_000).fill(65)
expect(bytesToBase64(bytes)).toBe(Buffer.from(bytes).toString("base64"))
})
})
describe("contentBytes", () => {
test("recovers byte counts from base64 and text", () => {
expect(contentBytes({ type: "binary", content: "iVBORw==", encoding: "base64" })).toBe(4)
expect(contentBytes({ type: "binary", content: "iVBORwA=", encoding: "base64" })).toBe(5)
expect(contentBytes({ type: "text", content: "héllo" })).toBe(6)
})
})
describe("parseDelimited", () => {
test("handles quotes, embedded delimiters, newlines, and CRLF", () => {
const parsed = parseDelimited('name,note\r\n"Smith, J","says ""hi""\nand more"\nplain,\n', ",")
expect(parsed.rows).toEqual([
["name", "note"],
["Smith, J", 'says "hi"\nand more'],
["plain", ""],
])
expect(parsed.total).toBe(3)
expect(parsed.columns).toBe(2)
})
test("counts rows past the limit without keeping them", () => {
const parsed = parseDelimited("a\tb\n1\t2\n3\t4\n5\t6", "\t", 2)
expect(parsed.rows).toHaveLength(2)
expect(parsed.total).toBe(4)
})
})
describe("resolveArtifactPath", () => {
test.each([
["docs", "guide.md", "docs/guide.md"],
["docs", "./img/a.png", "docs/img/a.png"],
["docs/api", "../index.md", "docs/index.md"],
["", "src/app.ts", "src/app.ts"],
["docs", "sub\\win.md", "docs/sub/win.md"],
["", "docs/guide.md", "docs/guide.md"],
["/tmp/notes/", "../out/a.pdf", "/tmp/out/a.pdf"],
["C:/tmp/notes/", "img.png", "C:/tmp/notes/img.png"],
["/repo", "../shared/report.pdf", "/shared/report.pdf"],
])("resolves %s + %s", (base, href, expected) => {
expect(resolveArtifactPath(base, href)).toBe(expected)
})
test.each([
["docs", "../../etc/passwd"],
["", "../x"],
["docs", "/abs/path"],
])("rejects %s + %s", (base, href) => {
expect(resolveArtifactPath(base, href)).toBeUndefined()
})
})
@@ -1,211 +0,0 @@
import type { FileContent } from "@/runtime/server/types"
export type ArtifactKind =
| "image"
| "svg"
| "audio"
| "video"
| "pdf"
| "html"
| "markdown"
| "mermaid"
| "table"
| "font"
| "text"
const mimes = new Map([
["png", "image/png"],
["jpg", "image/jpeg"],
["jpeg", "image/jpeg"],
["gif", "image/gif"],
["webp", "image/webp"],
["avif", "image/avif"],
["bmp", "image/bmp"],
["ico", "image/x-icon"],
["tif", "image/tiff"],
["tiff", "image/tiff"],
["heic", "image/heic"],
["svg", "image/svg+xml"],
["mp3", "audio/mpeg"],
["wav", "audio/wav"],
["ogg", "audio/ogg"],
["oga", "audio/ogg"],
["m4a", "audio/mp4"],
["aac", "audio/aac"],
["flac", "audio/flac"],
["opus", "audio/ogg"],
["weba", "audio/webm"],
["mp4", "video/mp4"],
["m4v", "video/mp4"],
["webm", "video/webm"],
["mov", "video/quicktime"],
["ogv", "video/ogg"],
["mkv", "video/x-matroska"],
["pdf", "application/pdf"],
["html", "text/html"],
["htm", "text/html"],
["md", "text/markdown"],
["markdown", "text/markdown"],
["mdx", "text/markdown"],
["mmd", "text/vnd.mermaid"],
["mermaid", "text/vnd.mermaid"],
["csv", "text/csv"],
["tsv", "text/tab-separated-values"],
["ttf", "font/ttf"],
["otf", "font/otf"],
["woff", "font/woff"],
["woff2", "font/woff2"],
])
export function artifactExtension(path: string) {
const name = path.split(/[\\/]/).pop() ?? ""
const index = name.lastIndexOf(".")
if (index <= 0) return ""
return name.slice(index + 1).toLowerCase()
}
export function artifactMime(path: string) {
return mimes.get(artifactExtension(path))
}
export function artifactKind(path: string): ArtifactKind {
const mime = artifactMime(path)
if (!mime) return "text"
if (mime === "image/svg+xml") return "svg"
if (mime === "application/pdf") return "pdf"
if (mime === "text/html") return "html"
if (mime === "text/markdown") return "markdown"
if (mime === "text/vnd.mermaid") return "mermaid"
if (mime === "text/csv" || mime === "text/tab-separated-values") return "table"
if (mime.startsWith("image/")) return "image"
if (mime.startsWith("audio/")) return "audio"
if (mime.startsWith("font/")) return "font"
return "video"
}
/** Kinds whose bytes are kept as base64 so media elements can play them without a text round trip. */
const binaryKinds = new Set<ArtifactKind>(["image", "audio", "video", "pdf", "font"])
/** Text files never contain NUL; a NUL in the first 8 KiB marks an unknown binary. */
function isBinaryBytes(bytes: Uint8Array) {
return bytes.subarray(0, 8192).includes(0)
}
export function bytesToBase64(bytes: Uint8Array) {
const parts: string[] = []
for (let index = 0; index < bytes.length; index += 0x8000) {
parts.push(String.fromCharCode(...bytes.subarray(index, index + 0x8000)))
}
return btoa(parts.join(""))
}
/** Media above this stays a placeholder: base64 encoding on the main thread and the LRU budget both suffer. */
export const MAX_MEDIA_BYTES = 25 * 1024 * 1024
export function fileContentFromBytes(path: string, bytes: Uint8Array): FileContent {
const kind = artifactKind(path)
const mimeType = artifactMime(path)
if (binaryKinds.has(kind)) {
if (bytes.length > MAX_MEDIA_BYTES) return { type: "binary", content: "", size: bytes.length }
return { type: "binary", content: bytesToBase64(bytes), encoding: "base64", mimeType }
}
// Unknown binaries keep no bytes: the viewer only shows a placeholder for them.
if (kind === "text" && isBinaryBytes(bytes)) return { type: "binary", content: "", size: bytes.length }
return { type: "text", content: new TextDecoder().decode(bytes), mimeType }
}
/** Approximate on-disk size of loaded content. */
export function contentBytes(content: FileContent) {
if (content.size !== undefined) return content.size
if (content.encoding === "base64") {
const padding = content.content.endsWith("==") ? 2 : content.content.endsWith("=") ? 1 : 0
return Math.floor((content.content.length * 3) / 4) - padding
}
return new TextEncoder().encode(content.content).length
}
/**
* Parse RFC 4180 style delimited text. Quoted fields may contain the delimiter, newlines, and
* doubled quotes. Rows beyond `limit` are counted but not returned.
*/
export function parseDelimited(text: string, delimiter: string, limit = 1000) {
const rows: string[][] = []
let row: string[] = []
let field = ""
let quoted = false
let total = 0
const endRow = () => {
row.push(field)
field = ""
const blank = row.length === 1 && row[0] === ""
if (!blank) {
total++
if (rows.length < limit) rows.push(row)
}
row = []
}
for (let index = 0; index < text.length; index++) {
const char = text[index]!
if (quoted) {
if (char !== '"') {
field += char
continue
}
if (text[index + 1] === '"') {
field += '"'
index++
continue
}
quoted = false
continue
}
if (char === '"' && field === "") {
quoted = true
continue
}
if (char === delimiter) {
row.push(field)
field = ""
continue
}
if (char === "\r") continue
if (char === "\n") {
endRow()
continue
}
field += char
}
if (field !== "" || row.length > 0) endRow()
const columns = rows.reduce((max, current) => Math.max(max, current.length), 0)
return { rows, total, columns }
}
/** Build a blob URL from loaded content. Callers revoke it when the viewer unmounts. */
export function blobUrlFromContent(content: FileContent) {
const type = content.mimeType ?? "application/octet-stream"
if (content.encoding !== "base64") return URL.createObjectURL(new Blob([content.content], { type }))
const raw = atob(content.content)
const bytes = Uint8Array.from(raw, (char) => char.charCodeAt(0))
return URL.createObjectURL(new Blob([bytes], { type }))
}
/**
* Resolve a relative link against a directory. A relative base yields a workspace-relative path and
* an absolute base an absolute one; undefined when the link climbs past the base's root.
*/
export function resolveArtifactPath(base: string, href: string) {
const target = href.replaceAll("\\", "/")
if (target.startsWith("/")) return undefined
const dir = base.replaceAll("\\", "/")
const segments = [...dir.split("/").filter(Boolean)]
for (const segment of target.split("/")) {
if (!segment || segment === ".") continue
if (segment !== "..") {
segments.push(segment)
continue
}
if (segments.length === 0) return undefined
segments.pop()
}
return `${dir.startsWith("/") ? "/" : ""}${segments.join("/")}`
}
+5 -10
View File
@@ -5,12 +5,11 @@ import { createSimpleContext } from "@opencode/ui/context"
import { showToast } from "@/shell/notifications/toast"
import { useParams } from "@solidjs/router"
import { base64Encode } from "@opencode/util/encode"
import { getDirectory, getFilename } from "@opencode/util/path"
import { getFilename } from "@opencode/util/path"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useLanguage } from "@/runtime/i18n/language"
import { useLayout } from "@/shell/state/layout"
import { createPathHelpers } from "./path"
import { fileContentFromBytes } from "./artifact"
import {
approxBytes,
evictContentLru,
@@ -186,17 +185,14 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
setLoading(file)
// Files outside the workspace are read from their own directory, like markdown images.
// The trailing separator from getDirectory keeps "/" and "C:/" valid, like readLocalImage.
const request = path.absolute(file)
? { path: getFilename(file), location: { directory: getDirectory(file) } }
: { path: file, location: { directory } }
const promise = serverSDK.api.file
.read(request)
.read({ path: file, location: { directory } })
.then((data) => {
if (scope() !== directory) return
const content = fileContentFromBytes(file, data)
const content = { type: "text" as const, content: new TextDecoder().decode(data) }
setLoaded(file, content)
if (!content) return
touchFileContent(file, approxBytes(content))
evictContent(new Set([file]))
})
@@ -285,7 +281,6 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
return {
ready: () => view().ready(),
normalize: path.normalize,
absolute: path.absolute,
tab: path.tab,
pathFromTab: path.pathFromTab,
tree: {
@@ -21,24 +21,6 @@ describe("file path helpers", () => {
expect(path.normalize("c:\\repo\\src\\app.ts")).toBe("src\\app.ts")
})
test("keeps files outside the workspace absolute and round-trips them through tabs", () => {
const posix = createPathHelpers(() => "/repo")
expect(posix.normalize("/tmp/out/report.pdf")).toBe("/tmp/out/report.pdf")
expect(posix.absolute("/tmp/out/report.pdf")).toBe(true)
expect(posix.absolute("src/app.ts")).toBe(false)
expect(posix.tab("/tmp/out/report.pdf")).toBe("file:///tmp/out/report.pdf")
expect(posix.pathFromTab("file:///tmp/out/report.pdf")).toBe("/tmp/out/report.pdf")
expect(posix.normalize("/repository/x.ts")).toBe("/repository/x.ts")
const windows = createPathHelpers(() => "C:\\repo")
expect(windows.normalize("C:\\tmp\\font.ttf")).toBe("C:\\tmp\\font.ttf")
expect(windows.normalize("file:///C:/tmp/font.ttf")).toBe("C:/tmp/font.ttf")
expect(windows.absolute("C:/tmp/font.ttf")).toBe(true)
expect(windows.tab("C:/tmp/font.ttf")).toBe("file:///C:/tmp/font.ttf")
expect(windows.pathFromTab("file:///C:/tmp/font.ttf")).toBe("C:/tmp/font.ttf")
expect(windows.pathFromTab("file:///C:/repo/src/app.ts")).toBe("src/app.ts")
})
test("normalizes Windows directory separators", () => {
const path = createPathHelpers(() => "C:\\repo")
expect(path.normalizeDir("frontend\\")).toBe("frontend")
+6 -12
View File
@@ -105,11 +105,7 @@ export function createPathHelpers(scope: () => string) {
const normalize = (input: string) => {
const root = scope()
// file:///C:/dir becomes /C:/dir once the protocol is gone; restore the drive form.
let path = unquoteGitPath(decodeFilePath(stripQueryAndHash(stripFileProtocol(input)))).replace(
/^[/\\]([A-Za-z]:)/,
"$1",
)
let path = unquoteGitPath(decodeFilePath(stripQueryAndHash(stripFileProtocol(input))))
// Separator-agnostic prefix stripping for Cygwin/native Windows compatibility
// Only case-insensitive on Windows (drive letter or UNC paths)
@@ -120,21 +116,20 @@ export function createPathHelpers(scope: () => string) {
canonPath.startsWith(canonRoot) &&
(canonRoot.endsWith("/") || canonPath === canonRoot || canonPath[canonRoot.length] === "/")
) {
// Slice from original path to preserve native separators, then drop the separator itself.
path = path.slice(root.length).replace(/^[/\\]/, "")
// Slice from original path to preserve native separators
path = path.slice(root.length)
}
if (path.startsWith("./") || path.startsWith(".\\")) {
path = path.slice(2)
}
// An absolute path that is not under the root stays absolute; it is a file outside the workspace.
if (path.startsWith("/") || path.startsWith("\\")) {
path = path.slice(1)
}
return path
}
/** Whether a normalized path points outside the workspace root. */
const absolute = (path: string) => /^[A-Za-z]:[/\\]/.test(path) || path.startsWith("/") || path.startsWith("\\\\")
const tab = (input: string) => {
const path = normalize(input)
return `file://${encodeFilePath(path)}`
@@ -154,7 +149,6 @@ export function createPathHelpers(scope: () => string) {
return {
normalize,
absolute,
tab,
pathFromTab,
normalizeDir,
+1 -21
View File
@@ -1,5 +1,4 @@
import { readFileSync } from "node:fs"
import { createRequire } from "node:module"
import solidPlugin from "vite-plugin-solid"
import tailwindcss from "@tailwindcss/vite"
import { fileURLToPath } from "url"
@@ -18,24 +17,6 @@ if (tailwindGenerate && typeof tailwindHotUpdate === "function") {
}
}
// The markdown worker imports these directly, so they are served unbundled to keep worker startup
// stable. Vite applies `exclude` to every import inside a pre-bundle too, which would leave a bare
// `import "marked"` in mermaid's chunk that the browser cannot resolve from this package.
const workerDeps = ["@shikijs/stream", "marked", "marked-shiki", "remend"]
/** @type {import("rolldown").Plugin} */
const bundleNestedWorkerDeps = {
name: "opencode-desktop:bundle-nested-worker-deps",
resolveId(id, importer) {
if (!importer || !workerDeps.includes(id) || !importer.includes("node_modules")) return
try {
return createRequire(importer).resolve(id)
} catch {
return
}
},
}
export const channel = (() => {
const raw = process.env.OPENCODE_CHANNEL
if (raw === "local" || raw === "dev" || raw === "beta" || raw === "prod") return raw
@@ -63,9 +44,8 @@ export default [
format: "es",
},
optimizeDeps: {
exclude: workerDeps,
exclude: ["@shikijs/stream", "marked", "marked-shiki", "remend"],
include: ["@opencode/session-ui > mermaid", "@opencode/session-ui > mermaid > katex"],
rolldownOptions: { plugins: [bundleNestedWorkerDeps] },
},
}
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/cli",
"version": "2.0.12",
"version": "2.0.10",
"type": "module",
"license": "MIT",
"bin": {
+3 -13
View File
@@ -55,7 +55,7 @@ try {
if ((await pluginIDs(info.url, headers)).includes("smoke")) throw new Error("Smoke plugin existed before creation")
const plugin = path.join(root, ".opencode", "plugins", "smoke.ts")
await fs.writeFile(plugin, pluginSource())
await waitForPlugin(info.url, headers, plugin)
await waitForPlugin(info.url, headers)
const unauthorizedInfo = await fetch(new URL("/api/info", info.url), {
signal: AbortSignal.timeout(5_000),
@@ -139,13 +139,7 @@ async function waitForReady(url: string, headers: HeadersInit) {
}
function exitsWithin(process: Bun.Subprocess, milliseconds: number) {
return new Promise<boolean>((resolve) => {
const timeout = setTimeout(() => resolve(false), milliseconds)
process.exited.then(() => {
clearTimeout(timeout)
resolve(true)
})
})
return Promise.race([process.exited.then(() => true), Bun.sleep(milliseconds).then(() => false)])
}
function pluginSource() {
@@ -165,15 +159,11 @@ async function pluginIDs(url: string, headers: HeadersInit) {
)
}
async function waitForPlugin(url: string, headers: HeadersInit, plugin: string) {
async function waitForPlugin(url: string, headers: HeadersInit) {
const deadline = Date.now() + 10_000
let attempt = 0
while (Date.now() < deadline) {
if ((await pluginIDs(url, headers)).includes("smoke")) return
await Bun.sleep(25)
// Native watchers may coalesce a single creation edge. Keep changing valid source so
// the smoke proves that a later native event is delivered.
if (++attempt % 10 === 0) await fs.writeFile(plugin, `${pluginSource()}// watcher retry ${attempt}\n`)
}
throw new Error("Compiled service did not discover the created plugin")
}
@@ -1,9 +1,8 @@
import { OpenCode } from "@opencode/client"
import { Service } from "@opencode/client/effect/service"
import { Session } from "@opencode/schema/session"
import { SessionMessage } from "@opencode/schema/session-message"
import { SessionTransfer } from "@opencode/schema/session-transfer"
import { Effect, Option, Predicate, Schema } from "effect"
import { Effect, Option, Schema } from "effect"
import { EOL } from "node:os"
import path from "node:path"
import { Commands } from "../../commands"
@@ -24,13 +23,7 @@ export default Runtime.handler(
catch: (cause) =>
new Error(`Failed to read session data: ${cause instanceof Error ? cause.message : String(cause)}`),
})
const raw = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))(text)
// Exports written before provider blobs were renamed to `native` still carry the old keys.
const data = yield* Schema.decodeUnknownEffect(SessionTransfer.Data)(
Predicate.isObject(raw) && Array.isArray(raw.messages)
? { ...raw, messages: raw.messages.map(SessionMessage.persisted) }
: raw,
)
const data = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(SessionTransfer.Data))(text)
const encoded = Schema.encodeSync(SessionTransfer.Data)(data)
const server = yield* ServerConnection.resolve({
server: Option.getOrUndefined(input.server),
+25 -68
View File
@@ -5,77 +5,34 @@ import { Runtime } from "../../framework/runtime"
import { Updater } from "../../services/updater"
import { handlePromptErrors } from "../../ui/prompt"
import { OPENCODE_VERSION } from "../../version"
import { stripVTControlCharacters } from "node:util"
export default Runtime.handler(
Commands.commands.upgrade,
Effect.fn("cli.upgrade")(
function* (input) {
intro("Upgrade")
const updater = yield* Updater.Service
const method = Option.getOrUndefined(input.method) ?? (yield* updater.method())
if (!method)
return yield* Effect.fail(
new Error("Could not detect the installation method. Pass --method to choose how to upgrade OpenCode."),
)
log.info(`Using method: ${method}`)
const target = Option.getOrUndefined(input.target) ?? (yield* updater.latest())
const version = target.trim().replace(/^v/, "")
if (version === OPENCODE_VERSION) {
log.warn(`OpenCode upgrade skipped: ${version} is already installed`)
outro("Done")
return
}
log.info(`From ${OPENCODE_VERSION}${version}`)
const progress = spinner()
progress.start("Upgrading...")
yield* updater.upgrade(method, target).pipe(
Effect.tap(() => Effect.sync(() => progress.stop("Upgrade complete"))),
Effect.tapCause(() => Effect.sync(() => progress.stop("Upgrade failed", 1))),
Effect.fn("cli.upgrade")(function* (input) {
intro("Upgrade")
const updater = yield* Updater.Service
const method = Option.getOrUndefined(input.method) ?? (yield* updater.method())
if (!method)
return yield* Effect.fail(
new Error("Could not detect the installation method. Pass --method to choose how to upgrade OpenCode."),
)
log.info(`Using method: ${method}`)
const target = Option.getOrUndefined(input.target) ?? (yield* updater.latest())
const version = target.trim().replace(/^v/, "")
if (version === OPENCODE_VERSION) {
log.warn(`OpenCode upgrade skipped: ${version} is already installed`)
outro("Done")
},
(effect) =>
handlePromptErrors(
effect.pipe(
Effect.mapError((error) =>
error instanceof Updater.UpgradeError ? new Error(formatUpgradeError(error), { cause: error }) : error,
),
),
),
),
return
}
log.info(`From ${OPENCODE_VERSION}${version}`)
const progress = spinner()
progress.start("Upgrading...")
yield* updater.upgrade(method, target).pipe(
Effect.tap(() => Effect.sync(() => progress.stop("Upgrade complete"))),
Effect.tapCause(() => Effect.sync(() => progress.stop("Upgrade failed", 1))),
)
outro("Done")
}, handlePromptErrors),
)
function formatUpgradeError(error: Updater.UpgradeError) {
const clean = (value: string) => stripVTControlCharacters(value).replaceAll("\r", "").trim()
const line = (value: string) => clean(value).replace(/\s+/g, " ")
const detail = wrap(clean(error.detail), Math.max(24, (process.stdout.columns ?? 80) - 7)).join("\n")
return [
line(error.title),
"",
detail,
...(error.command ? ["", field("Command", line(error.command))] : []),
field("Retry", line(error.retry)),
].join("\n")
}
function field(label: string, value: string) {
const prefix = label.padEnd(9)
const width = Math.max(24, Math.min(61, (process.stdout.columns ?? 80) - prefix.length - 10))
const lines = wrap(value, width)
return lines.map((line, index) => `${index === 0 ? prefix : " ".repeat(prefix.length)}${line}`).join("\n")
}
function wrap(value: string, width: number) {
return value.split("\n").flatMap((source) => {
if (source.length <= width) return [source]
return source.split(/\s+/).reduce<string[]>((result, word) => {
const index = result.length - 1
if (index < 0 || result[index].length + word.length + 1 > width) result.push(word)
else result[index] += ` ${word}`
return result
}, [])
})
}
+2 -2
View File
@@ -7,7 +7,7 @@ import { produce, type Draft } from "immer"
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
import path from "path"
import { ConfigMigration } from "./migrate"
import { Info, normalizeLegacyTabs, SchemaURL } from "./schema"
import { Info, SchemaURL } from "./schema"
export * from "./schema"
@@ -119,7 +119,7 @@ function merge(...values: readonly (Info | undefined)[]) {
return Option.getOrElse(
decode(
values.reduce<Record<string, unknown>>(
(result, value) => mergeRecords(result, normalizeLegacyTabs(value) ?? {}),
(result, value) => mergeRecords(result, value ?? {}),
{},
),
),
-8
View File
@@ -8,11 +8,3 @@ export const Info = Schema.Struct({
...Config.Info.fields,
})
export type Info = Schema.Schema.Type<typeof Info>
export function normalizeLegacyTabs(info: Info | undefined) {
if (info?.tabs?.enabled === undefined) return info
const tabs = { ...info.tabs }
tabs.mode ??= tabs.enabled ? "on" : "off"
delete tabs.enabled
return { ...info, tabs }
}
+2 -13
View File
@@ -1,8 +1,7 @@
#!/usr/bin/env bun
import { NodeRuntime, NodeServices } from "@effect/platform-node"
import { Cause, Effect } from "effect"
import { getErrorReported } from "effect/Runtime"
import { Effect } from "effect"
import { Commands } from "./commands/commands"
import { Runtime } from "./framework/runtime"
import { Observability } from "@opencode/util/observability"
@@ -131,15 +130,5 @@ Effect.gen(function* () {
Effect.provide(NodeServices.layer),
Effect.scoped,
Effect.tap(() => Effect.sync(() => process.exit(process.exitCode ?? 0))),
// runMain's default reporter logs the fatal cause to stdout. Write it to stderr instead: the
// desktop and `Service.ensure` only capture stderr from `serve --service`, so this is the only
// channel through which a startup failure's reason reaches the user.
Effect.tapCause((cause) =>
Effect.sync(() => {
if (Cause.hasInterruptsOnly(cause)) return
if (!getErrorReported(Cause.squash(cause))) return
process.stderr.write(Cause.pretty(cause) + "\n")
}),
),
NodeRuntime.runMain({ disableErrorReporting: true }),
NodeRuntime.runMain,
)
+1 -1
View File
@@ -564,7 +564,7 @@ export async function runNonInteractivePrompt(input: Input) {
messageID: message.id,
type: "reasoning",
text,
metadata: item.native,
metadata: item.state,
time: { start: message.time.created, end: timestamp },
}
renderedReasoning.set(key, item.text)
+27 -187
View File
@@ -5,9 +5,7 @@ import { Context, Duration, Effect, FileSystem, Layer, Option, Ref, Schema } fro
import { ChildProcess } from "effect/unstable/process"
import { parse, type ParseError } from "jsonc-parser"
import path from "node:path"
import { stripVTControlCharacters } from "node:util"
import { action, parseReleaseVersion, type Policy } from "./updater-action"
import { errorMessage } from "../util/error"
export const methods = ["curl", "npm", "pnpm", "bun", "yarn", "vp", "brew"] as const
@@ -15,82 +13,10 @@ export type Method = (typeof methods)[number]
export type RunResult = { readonly type: "available" | "installed"; readonly version: string }
export type CheckResult = RunResult | { readonly type: "unavailable"; readonly message: string }
export class UpgradeError extends Error {
readonly title: string
readonly detail: string
readonly command?: string
readonly retry: string
constructor(
input: {
readonly title: string
readonly detail: string
readonly command?: string
readonly retry: string
},
options?: ErrorOptions,
) {
super(input.detail, options)
this.name = "UpgradeError"
this.title = input.title
this.detail = input.detail
this.command = input.command
this.retry = input.retry
}
}
const decodeVpPackages = Schema.decodeUnknownOption(
Schema.fromJsonString(Schema.Array(Schema.Struct({ name: Schema.String }))),
)
const installNames: Record<Method, string> = {
curl: "The OpenCode installer",
npm: "npm",
pnpm: "pnpm",
bun: "Bun",
yarn: "Yarn",
vp: "Vite+",
brew: "Homebrew",
}
function conciseDetail(input: string) {
const lines = stripVTControlCharacters(input)
.trim()
.replaceAll("\r", "\n")
.split("\n")
.map((line) => line.trimEnd())
.filter((line) => line.trim().length > 0)
const tail = lines.slice(-12).join("\n")
const clipped = tail.length > 2_000
const detail = clipped ? `${tail.slice(-1_999)}` : tail
if (!detail) return
if (lines.length <= 12 && !clipped) return detail
return `${detail}\n\nOutput shortened to the last 12 lines.`
}
function errorDetail(cause: unknown): string {
if (cause instanceof AppProcess.AppProcessError) {
const stderr = conciseDetail(cause.stderr ?? "")
if (stderr) return stderr
if (cause.cause !== undefined) return errorDetail(cause.cause)
return cause.message
}
if (cause instanceof Error) {
const detail = cause.cause === undefined ? undefined : errorDetail(cause.cause)
if (!detail || detail === cause.message) return cause.message
return `${cause.message}: ${detail}`
}
return errorMessage(cause)
}
function resultDetail(result: { code: number; stdout: string; stderr: string }) {
return (
conciseDetail(result.stderr) ??
conciseDetail(result.stdout) ??
`The command exited with code ${result.code} without any error output.`
)
}
export interface Interface {
readonly run: (onInstall?: (version: string) => void) => Effect.Effect<RunResult | undefined>
readonly check: () => Effect.Effect<CheckResult | undefined, Error>
@@ -98,9 +24,9 @@ export interface Interface {
readonly method: () => Effect.Effect<Method | undefined>
readonly latest: () => Effect.Effect<string, Error>
readonly upgrade: (method: Method, version: string) => Effect.Effect<void, Error>
readonly removal: (
method: Method,
) => { readonly command: ReadonlyArray<string>; readonly run: Effect.Effect<void, Error> } | undefined
readonly removal: (method: Method) =>
| { readonly command: ReadonlyArray<string>; readonly run: Effect.Effect<void, Error> }
| undefined
}
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Updater") {}
@@ -163,6 +89,7 @@ const make = Effect.gen(function* () {
stdout: result.stdout.toString("utf8"),
stderr: result.stderr.toString("utf8"),
})),
Effect.orElseSucceed(() => ({ code: 1, stdout: "", stderr: "" })),
)
})
@@ -192,11 +119,7 @@ const make = Effect.gen(function* () {
]
const results = yield* Effect.forEach(
checks,
(check) =>
exec(check.command).pipe(
Effect.orElseSucceed(() => ({ code: 1, stdout: "", stderr: "" })),
Effect.map((result) => ({ check, result })),
),
(check) => exec(check.command).pipe(Effect.map((result) => ({ check, result }))),
{ concurrency: "unbounded" },
)
return results.find((result) => {
@@ -221,7 +144,11 @@ const make = Effect.gen(function* () {
return {
command,
run: exec(command, "5 minutes").pipe(
Effect.flatMap((result) => (result.code === 0 ? Effect.void : Effect.fail(new Error(resultDetail(result))))),
Effect.flatMap((result) =>
result.code === 0
? Effect.void
: Effect.fail(new Error(result.stderr.trim() || `Failed to uninstall with ${method}`)),
),
),
}
}
@@ -236,44 +163,14 @@ const make = Effect.gen(function* () {
signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]),
},
),
catch: (cause) =>
new UpgradeError(
{
title: "Could not check for OpenCode updates",
detail: errorDetail(cause),
retry: "Check your network, then run opencode upgrade again.",
},
{ cause },
),
catch: (cause) => new Error("Failed to check for updates", { cause }),
})
if (!response.ok)
return yield* Effect.fail(
new UpgradeError({
title: "Could not check for OpenCode updates",
detail: `The update service returned HTTP ${response.status}.`,
retry: "Try again in a few minutes.",
}),
)
if (!response.ok) return yield* Effect.fail(new Error(`Update check failed with status ${response.status}`))
const data: { version: string; metadata?: { package?: string } } = yield* Effect.tryPromise({
try: () => response.json(),
catch: (cause) =>
new UpgradeError(
{
title: "Could not read the OpenCode update information",
detail: errorDetail(cause),
retry: "Try again in a few minutes.",
},
{ cause },
),
catch: (cause) => new Error("Failed to read update information", { cause }),
})
if (!data.metadata?.package)
return yield* Effect.fail(
new UpgradeError({
title: "Could not read the OpenCode update information",
detail: "The update service returned incomplete release information.",
retry: "Try again in a few minutes.",
}),
)
if (!data.metadata?.package) return yield* Effect.fail(new Error("Update information did not include a package"))
return { package: data.metadata.package, version: data.version }
})
@@ -288,40 +185,6 @@ const make = Effect.gen(function* () {
fs.remove(directory, { recursive: true, force: true }).pipe(Effect.ignore),
)
const runUpgrade = (input: {
readonly method: Method
readonly command: string[]
readonly displayCommand?: string[]
readonly title?: string
readonly retry?: string
}) => {
const failure = (detail: string, cause?: unknown) =>
new UpgradeError(
{
title: input.title ?? `${installNames[input.method]} could not install OpenCode`,
detail,
command: (input.displayCommand ?? input.command).join(" "),
retry: input.retry ?? "Fix the issue above, then run opencode upgrade again.",
},
cause === undefined ? undefined : { cause },
)
return exec(input.command, "5 minutes").pipe(
Effect.flatMap((result) =>
result.code === 0 ? Effect.succeed(result) : Effect.fail(failure(resultDetail(result))),
),
Effect.mapError((cause) =>
cause instanceof UpgradeError
? cause
: failure(
cause instanceof AppProcess.AppProcessError && cause.stderr === undefined && cause.cause === undefined
? `Failed to update with ${input.method}`
: errorDetail(cause),
cause,
),
),
)
}
const upgrade = Effect.fnUntraced(function* (method: Method, input: string) {
if (!parseReleaseVersion(input)) return yield* Effect.fail(new Error(`Invalid version: ${input}`))
const version = input.trim().replace(/^v/, "")
@@ -349,54 +212,31 @@ const make = Effect.gen(function* () {
? ["vp", "install", "-g", "--force", target]
: ["vp", "update", "-g", target],
}
yield* Effect.scoped(
const result = yield* Effect.scoped(
Effect.gen(function* () {
if (method === "bun") {
// Bun does not prune old versions from its shared package cache.
yield* fs.makeDirectory(global.cache, { recursive: true })
const cache = yield* temporaryDirectory("update-")
return yield* runUpgrade({
method,
command: ["bun", "install", "--global", "--trust", "--cache-dir", cache, target],
displayCommand: ["bun", "install", "--global", "--trust", target],
})
return yield* exec(["bun", "install", "--global", "--trust", "--cache-dir", cache, target], "5 minutes")
}
if (method === "curl") {
yield* fs.makeDirectory(global.cache, { recursive: true })
const directory = yield* temporaryDirectory("update-")
const installer = path.join(directory, "install")
yield* runUpgrade({
method,
command: ["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"],
displayCommand: ["curl", "-fsSL", "https://opencode.ai/v2/install"],
title: "Could not download the OpenCode installer",
retry: "Check your network, then run opencode upgrade again.",
})
return yield* runUpgrade({
method,
command: ["bash", installer, "--version", version, "--no-modify-path"],
displayCommand: ["opencode", "upgrade", version, "--method", "curl"],
title: "The OpenCode installer failed",
})
const download = yield* exec(
["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"],
"5 minutes",
)
if (download.code !== 0) return download
return yield* exec(["bash", installer, "--version", version, "--no-modify-path"], "5 minutes")
}
if (method === "brew") return yield* runUpgrade({ method, command: ["brew", "upgrade", packageName] })
return yield* runUpgrade({ method, command: commands[method] })
if (method === "brew") return yield* exec(["brew", "upgrade", packageName], "5 minutes")
return yield* exec(commands[method], "5 minutes")
}),
).pipe(
Effect.mapError((cause) =>
cause instanceof UpgradeError
? cause
: new UpgradeError(
{
title: "Could not prepare the OpenCode upgrade",
detail: errorDetail(cause),
retry: "Fix the issue above, then run opencode upgrade again.",
},
{ cause },
),
),
Effect.asVoid,
)
).pipe(Effect.mapError((cause) => new Error(`Failed to update with ${method}`, { cause })))
if (result.code === 0) return
return yield* Effect.fail(new Error(result.stderr.trim() || `Failed to update with ${method}`))
})
const inspect = Effect.fnUntraced(function* () {
+2 -22
View File
@@ -78,7 +78,7 @@ test("merges inline CLI config content over the global config", async () => {
await Bun.write(
file,
JSON.stringify({
tabs: { mode: "on", scope: "global" },
tabs: { enabled: true, scope: "global" },
keybinds: { "app.exit": "ctrl+q" },
plugins: ["global"],
animations: true,
@@ -105,7 +105,7 @@ test("merges inline CLI config content over the global config", async () => {
}),
)
expect(result.loaded.tabs).toEqual({ mode: "off", scope: "global" })
expect(result.loaded.tabs).toEqual({ enabled: false, scope: "global" })
expect(result.loaded.keybinds).toEqual({ "app.exit": "ctrl+q", "help.show": false })
expect(result.loaded.plugins).toEqual(["inline"])
expect(result.updated).toMatchObject({ animations: false, mouse: false })
@@ -116,26 +116,6 @@ test("merges inline CLI config content over the global config", async () => {
}
})
test("reads the legacy tabs toggle without rewriting it", async () => {
await using directory = await tmpdir()
const file = path.join(directory.path, "cli.json")
await Bun.write(file, JSON.stringify({ tabs: { enabled: false } }))
const config = await run(
directory.path,
Effect.gen(function* () {
const service = yield* Config.Service
expect((yield* service.get()).tabs).toEqual({ mode: "off" })
return yield* service.update((draft) => {
draft.animations = false
})
}),
)
expect(config.tabs).toEqual({ mode: "off" })
expect(await Bun.file(file).json()).toEqual({ tabs: { enabled: false }, animations: false })
})
test("migrates tui and kv config into cli.json", async () => {
await using directory = await tmpdir()
await Bun.write(
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/client",
"version": "2.0.12",
"version": "2.0.10",
"type": "module",
"license": "MIT",
"repository": {
+15
View File
@@ -241,6 +241,7 @@ export type SessionSwitchModelOperation<E = never> = (
export type SessionUpdateInput = {
readonly sessionID: Session.ID
readonly title?: string | undefined
readonly metadata?: Session.Metadata | undefined
readonly permissions?: Permission.Ruleset | undefined
}
export type SessionUpdateOutput = void
@@ -516,6 +517,20 @@ export type SessionLogOutput =
| undefined
readonly data: { readonly sessionID: Session.ID; readonly title: string }
}
| {
readonly id: Event.ID
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.metadata.updated"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?:
| {
readonly directory: AbsolutePath
readonly workspaceID?: (string & Brand.Brand<"Workspace.ID">) | undefined
}
| undefined
readonly data: { readonly sessionID: Session.ID; readonly metadata: Session.Metadata }
}
| {
readonly id: Event.ID
readonly created: number
@@ -453,7 +453,7 @@ const EndpointSessionUpdate = (raw: RawClient["server.session"]) => (input: Sess
preserveEffect<SessionUpdateOutput>()(
raw["session.update"]({
params: { sessionID: input["sessionID"] },
payload: { title: input["title"], permissions: input["permissions"] },
payload: { title: input["title"], metadata: input["metadata"], permissions: input["permissions"] },
}).pipe(Effect.mapError(mapClientError)),
)
@@ -662,7 +662,7 @@ export function make(options: ClientOptions) {
{
method: "PATCH",
path: `/api/session/${encodeURIComponent(input.sessionID)}`,
body: { title: input["title"], permissions: input["permissions"] },
body: { title: input["title"], metadata: input["metadata"], permissions: input["permissions"] },
successStatus: 204,
declaredStatuses: [400, 401, 404],
empty: true,
+48 -23
View File
@@ -509,12 +509,12 @@ export type PromptAgentAttachment = { name: string; mention?: PromptMention }
export type PromptSkillAttachment = { id: string; name: string; text?: string; mention?: PromptMention }
export type SessionMessageAssistantText = { type: "text"; text: string; native?: SessionMessageProviderState }
export type SessionMessageAssistantText = { type: "text"; text: string; state?: SessionMessageProviderState }
export type SessionMessageAssistantReasoning = {
type: "reasoning"
text: string
native?: SessionMessageProviderState
state?: SessionMessageProviderState
time?: { created: number; completed?: number }
}
@@ -1221,6 +1221,16 @@ export type SessionMoved = {
export type SessionInboxMovePayload1 = { location: LocationRef; projectID: string; subpath?: string }
export type SessionMetadataUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.metadata.updated"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; metadata: SessionMetadata }
}
export type SessionShellStarted = {
id: string
created: number
@@ -1354,6 +1364,15 @@ export type SessionToolCalled = {
}
}
export type SessionMessageAssistantText1 = { type: "text"; text: string; state?: SessionMessageProviderState1 }
export type SessionMessageAssistantReasoning1 = {
type: "reasoning"
text: string
state?: SessionMessageProviderState1
time?: { created: number; completed?: number }
}
export type ToolContent1 = ToolTextContent | ToolFileContent1
export type FormNumberField = {
@@ -1753,7 +1772,7 @@ export type SessionMessageCompactionCompleted = {
status: "completed"
reason: "auto" | "manual"
model?: ModelRef
native?: SessionMessageProviderState
providerState?: SessionMessageProviderState
summary: string
recent: string
providerContext?: SessionProviderContext
@@ -2219,7 +2238,7 @@ export type SessionMessageAssistant = {
snapshot?: { start?: string; end?: string; files?: Array<string> }
finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
rawFinish?: string
native?: SessionMessageProviderState
providerState?: SessionMessageProviderState
cost?: MoneyUSD
tokens?: TokenUsageInfo
error?: SessionStructuredError
@@ -2227,13 +2246,8 @@ export type SessionMessageAssistant = {
}
export type SessionMessageAssistantContentEncoded =
| { type: "text"; text: string; state?: SessionMessageProviderState1 }
| {
type: "reasoning"
text: string
time?: { created: number; completed?: number }
state?: SessionMessageProviderState1
}
| SessionMessageAssistantText1
| SessionMessageAssistantReasoning1
| SessionMessageAssistantTool1
export type FormInfo = { id: string; sessionID: string; title: string; metadata?: FormMetadata; fields: FormFields }
@@ -2304,6 +2318,7 @@ export type SessionEventDurable =
| SessionModelSelected
| SessionMoved
| SessionRenamed
| SessionMetadataUpdated
| SessionPermissions
| SessionViewed
| SessionDeleted
@@ -2366,6 +2381,7 @@ export type V2Event =
| SessionModelSelected
| SessionMoved
| SessionRenamed
| SessionMetadataUpdated
| SessionPermissions
| SessionViewed
| SessionUsageUpdated
@@ -3098,11 +3114,11 @@ export type SessionImportInput = {
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly content: ReadonlyArray<
| { readonly type: "text"; readonly text: string; readonly native?: { readonly [x: string]: JsonValue } }
| { readonly type: "text"; readonly text: string; readonly state?: { readonly [x: string]: JsonValue } }
| {
readonly type: "reasoning"
readonly text: string
readonly native?: { readonly [x: string]: JsonValue }
readonly state?: { readonly [x: string]: JsonValue }
readonly time?: { readonly created: number; readonly completed?: number }
}
| {
@@ -3176,7 +3192,7 @@ export type SessionImportInput = {
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly rawFinish?: string
readonly native?: { readonly [x: string]: JsonValue }
readonly providerState?: { readonly [x: string]: JsonValue }
readonly cost?: number
readonly tokens?: {
readonly input: number
@@ -3210,7 +3226,7 @@ export type SessionImportInput = {
readonly status: "completed"
readonly reason: "auto" | "manual"
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly native?: { readonly [x: string]: JsonValue }
readonly providerState?: { readonly [x: string]: JsonValue }
readonly summary: string
readonly recent: string
readonly providerContext?: {
@@ -3415,11 +3431,11 @@ export type SessionImportInput = {
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly content: ReadonlyArray<
| { readonly type: "text"; readonly text: string; readonly native?: { readonly [x: string]: JsonValue } }
| { readonly type: "text"; readonly text: string; readonly state?: { readonly [x: string]: JsonValue } }
| {
readonly type: "reasoning"
readonly text: string
readonly native?: { readonly [x: string]: JsonValue }
readonly state?: { readonly [x: string]: JsonValue }
readonly time?: { readonly created: number; readonly completed?: number }
}
| {
@@ -3493,7 +3509,7 @@ export type SessionImportInput = {
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly rawFinish?: string
readonly native?: { readonly [x: string]: JsonValue }
readonly providerState?: { readonly [x: string]: JsonValue }
readonly cost?: number
readonly tokens?: {
readonly input: number
@@ -3527,7 +3543,7 @@ export type SessionImportInput = {
readonly status: "completed"
readonly reason: "auto" | "manual"
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly native?: { readonly [x: string]: JsonValue }
readonly providerState?: { readonly [x: string]: JsonValue }
readonly summary: string
readonly recent: string
readonly providerContext?: {
@@ -3732,11 +3748,11 @@ export type SessionImportInput = {
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly content: ReadonlyArray<
| { readonly type: "text"; readonly text: string; readonly native?: { readonly [x: string]: JsonValue } }
| { readonly type: "text"; readonly text: string; readonly state?: { readonly [x: string]: JsonValue } }
| {
readonly type: "reasoning"
readonly text: string
readonly native?: { readonly [x: string]: JsonValue }
readonly state?: { readonly [x: string]: JsonValue }
readonly time?: { readonly created: number; readonly completed?: number }
}
| {
@@ -3810,7 +3826,7 @@ export type SessionImportInput = {
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly rawFinish?: string
readonly native?: { readonly [x: string]: JsonValue }
readonly providerState?: { readonly [x: string]: JsonValue }
readonly cost?: number
readonly tokens?: {
readonly input: number
@@ -3844,7 +3860,7 @@ export type SessionImportInput = {
readonly status: "completed"
readonly reason: "auto" | "manual"
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly native?: { readonly [x: string]: JsonValue }
readonly providerState?: { readonly [x: string]: JsonValue }
readonly summary: string
readonly recent: string
readonly providerContext?: {
@@ -3942,12 +3958,21 @@ export type SessionUpdateInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly title?: {
readonly title?: string | undefined
readonly metadata?: { readonly [x: string]: JsonValue } | undefined
readonly permissions?:
| ReadonlyArray<{ readonly action: string; readonly resource: string; readonly effect: "allow" | "deny" | "ask" }>
| undefined
}["title"]
readonly metadata?: {
readonly title?: string | undefined
readonly metadata?: { readonly [x: string]: JsonValue } | undefined
readonly permissions?:
| ReadonlyArray<{ readonly action: string; readonly resource: string; readonly effect: "allow" | "deny" | "ask" }>
| undefined
}["metadata"]
readonly permissions?: {
readonly title?: string | undefined
readonly metadata?: { readonly [x: string]: JsonValue } | undefined
readonly permissions?:
| ReadonlyArray<{ readonly action: string; readonly resource: string; readonly effect: "allow" | "deny" | "ask" }>
| undefined
+7 -8
View File
@@ -846,7 +846,7 @@ export function createData(config: CreateDataInput) {
existing.error = undefined
existing.finish = undefined
existing.rawFinish = undefined
existing.native = undefined
existing.providerState = undefined
existing.time.created = event.data.started
existing.time.streamed = undefined
existing.time.completed = undefined
@@ -880,7 +880,7 @@ export function createData(config: CreateDataInput) {
assistant.time.completed = event.created
assistant.finish = event.data.finish
assistant.rawFinish = event.data.rawFinish
assistant.native = event.data.providerState
assistant.providerState = event.data.providerState
assistant.cost = event.data.cost
assistant.tokens = event.data.tokens
if (event.data.snapshot) assistant.snapshot = { ...assistant.snapshot, end: event.data.snapshot }
@@ -892,7 +892,7 @@ export function createData(config: CreateDataInput) {
assistant.time.completed = event.created
assistant.finish = event.data.finish ?? "error"
assistant.rawFinish = event.data.rawFinish
assistant.native = event.data.providerState
assistant.providerState = event.data.providerState
assistant.error = event.data.error
assistant.retry = undefined
if (event.data.cost !== undefined && event.data.tokens !== undefined) {
@@ -914,7 +914,6 @@ export function createData(config: CreateDataInput) {
case "session.text.ended":
message.editText(event.data.sessionID, event.data.assistantMessageID, (text) => {
text.text = event.data.text
text.native = event.data.state
})
return
case "session.tool.input.started":
@@ -985,7 +984,7 @@ export function createData(config: CreateDataInput) {
assistant.content.push({
type: "reasoning",
text: "",
native: event.data.state,
state: event.data.state,
time: { created: event.created },
})
})
@@ -999,7 +998,7 @@ export function createData(config: CreateDataInput) {
message.editReasoning(event.data.sessionID, event.data.assistantMessageID, (reasoning) => {
reasoning.text = event.data.text
reasoning.time = { created: reasoning.time?.created ?? event.created, completed: event.created }
if (event.data.state !== undefined) reasoning.native = event.data.state
if (event.data.state !== undefined) reasoning.state = event.data.state
})
return
case "session.retry.scheduled":
@@ -1106,7 +1105,7 @@ export function createData(config: CreateDataInput) {
status: "completed",
reason: event.data.reason,
model: event.data.model,
native: event.data.providerState,
providerState: event.data.providerState,
providerContext: event.data.providerContext,
summary: event.data.text,
recent: event.data.recent,
@@ -1121,7 +1120,7 @@ export function createData(config: CreateDataInput) {
status: "completed",
reason: event.data.reason,
model: event.data.model,
native: event.data.providerState,
providerState: event.data.providerState,
providerContext: event.data.providerContext,
summary: event.data.text,
recent: event.data.recent,
@@ -135,7 +135,7 @@ test.each(["started", "cancelled", "failed"])(
status: "completed",
summary: "Summary",
model,
native: providerState,
providerState,
providerContext,
cost: 0.01,
tokens,
-2
View File
@@ -6,8 +6,6 @@
- Tool schemas are the model-facing Interface. Keep arguments minimal and natural to the operation; never add unrelated IDs as ambient capability tokens.
- State model-visible diagnostics, logs, tool descriptions, and instructions directly. The execution context is already clear; do not repeat `Code Mode` or `CodeMode` unless the distinction is necessary.
- When interpreter behavior or support changes, update `interpreter-support.md` and direct tests in the same PR.
- Program values are `Value` (`src/interpreter/objects.ts`); host values are `unknown` and are copied in at the boundaries (`fromHost`, `fromJson`). Do not widen program-facing signatures back to `unknown`.
- A built-in kind of object is one `Obj` subclass (`Wrapper` for host-backed data such as Date or Map, `Opaque` for machinery such as functions and promises) that overrides `tag`, `toString`, `toPrimitive`, `inspect`, `toHost`, and `iterator` as needed. Do not add `instanceof` ladders over the built-in classes elsewhere; ask the object.
## OpenAPI
+48 -98
View File
@@ -24,7 +24,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
and Map, RegExp, and generators serialize as `{}`. A bare `undefined` result is `null`.
Tool results come back the way `JSON.parse(JSON.stringify(result))` would. The table, where a value cannot
be JSON but what the program meant is clear: a promise is awaited (a rejection fails the program), a Set
crosses as an array, a URLSearchParams as its query string, an Error as `{ name, message, ...own enumerable }`, a
crosses as an array, a URLSearchParams as its query string, an Error as `{ name, message, ...own }`, a
Uint8Array is rejected with a hint to encode as text, and own `__proto__` keys are dropped so merging tool
inputs or results cannot replace a prototype. In-program `JSON.stringify` keeps JS behavior except for the
Error form and a promise, which is a `TypeError` with an await hint rather than a silent `{}`.
@@ -38,11 +38,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
10,000,000 elements (`Array(n)`, `length =`, `Array.from`, `split`, `matchAll`, `concat`, `flat`; below the JS
maximum of 2^32 - 1), and 10,000 pending promises at once. Exceeding one throws a `RangeError`. A single regular
expression match can still run long on a pathological pattern; the host regex engine has no interrupt hook.
- [x] A trailing comma after a rest parameter is a syntax error, with or without `"use strict"`.
- [x] A program that begins with `"use strict"` rejects `yield` as an identifier and duplicate parameter names at
parse time. Without it, `yield` is an ordinary binding.
- [ ] Duplicate parameter names in non-strict code throw when the function is called, instead of binding the last
parameter as JavaScript does.
- [ ] Strict-mode early errors: duplicate parameter names, `yield` as an identifier, and a trailing comma after a
rest parameter are accepted unless the program itself begins with `"use strict"`.
## Values and literals
@@ -56,7 +53,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] `NaN` and `Infinity` globals.
- [ ] BigInt literals and in-interpreter BigInt arithmetic; BigInt remains invalid at JSON-like host boundaries.
- [ ] Arbitrary Symbol primitive values and symbol-keyed properties. The confined `Symbol.iterator` and
`Symbol.asyncIterator` keys are available only for the iterator protocols.
`Symbol.asyncIterator` keys are available only for custom iterator protocols.
- [ ] Tagged-template calls.
- [ ] Getter and setter definitions in object literals.
@@ -87,6 +84,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] Destructuring reads through the prototype chain like member access: `const { constructor } = error` and
`const { slice } = values` find the inherited built-in.
- [ ] Member expressions as `for...in` targets (`for (x.y in obj)`).
- [ ] `IteratorClose` during destructuring should throw a `TypeError` when `return()` yields a non-object.
## Statements and control flow
@@ -94,10 +92,9 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] `if`/`else` and conditional expressions.
- [x] `switch`, including default clauses and fallthrough.
- [x] `for`, `while`, and `do...while`.
- [x] `for...of` over arrays, strings, Maps, Sets, URLSearchParams, Headers, Uint8Arrays, built-in iterators, custom
synchronous iterators, and confined synchronous generators. Abrupt completion invokes the iterator's optional `return()`.
- [x] `for...in` over own keys of plain objects, arrays, strings, and tool references. `null`, `undefined`, and other
non-objects iterate nothing. An un-awaited promise throws rather than iterating.
- [x] `for...of` over arrays, strings, Maps, Sets, URLSearchParams, Headers, custom synchronous iterators, and
confined synchronous generators. Abrupt completion invokes the iterator's optional `return()`.
- [x] `for...in` over own keys of plain objects, arrays, strings, and tool references; other values iterate nothing.
- [x] Unlabeled `break` and `continue`.
- [x] `try`, `catch`, optional catch bindings, and `finally`.
- [x] `throw` with arbitrary values.
@@ -111,8 +108,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
## Functions and callbacks
- [x] Function declarations, function expressions, and arrow functions.
- [x] Synchronous and `async` functions. A line break between `function` and the name is allowed, as in JavaScript;
a line break between `async` and `function` is not an async function.
- [x] Synchronous and `async` functions.
- [x] Closures, recursion, default parameters, rest parameters, and destructured parameters.
- [x] A call depth limit of 10000: deeper nesting throws a catchable `RangeError: Maximum call stack size exceeded`
at the overflowing call instead of running until the timeout. Callbacks invoked by built-ins count below the
@@ -124,8 +120,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] `Boolean`, `Number`, `String`, `parseInt`, `parseFloat`, `isFinite`, `isNaN`, and URI helpers as callbacks.
- [x] Built-in method references as callbacks, such as `values.map(Math.abs)`, `records.map(JSON.stringify)`,
`items.forEach(console.log)`, and `Promise.resolve(-1).then(Math.abs)`. Extra callback arguments a built-in
does not consume are ignored, like JS, and consumed arguments coerce, like JS (`"3.7".replace(/\d\.\d/,
Math.floor)` is `"3"`). A detached method loses its receiver, as in JS: `values.filter("abc".includes)` is a `TypeError`
does not consume are ignored, like JS; consumed arguments stay strictly validated (`Math.floor` still rejects a
string). A detached method loses its receiver, as in JS: `values.filter("abc".includes)` is a `TypeError`
because `includes` is called without a string `this`.
- [x] Constructors work as callbacks with JS call semantics: `Error` types construct (`messages.map(Error)`),
and new-requiring constructors (`Map`, `Set`, `URL`, `URLSearchParams`, `Headers`, `Promise`) throw a `TypeError`,
@@ -145,13 +141,11 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
assignments, object literal keys, and destructuring or parameter defaults.
- [x] Built-in functions are objects too, with `name` and `length` (`Math.max.length === 2`,
`Array.prototype.push.name === "push"`).
- [x] A named function expression's name is bound read-only inside its own body; assigning to it throws a
`TypeError`, as in strict mode.
- [x] Redeclaring a function in the same scope, or alongside a `var`, is allowed: the last declaration wins.
- [x] Generator functions have their own `prototype` (inheriting the shared generator prototype), so
`g() instanceof g` holds. Plain functions have none, since they cannot construct.
- [x] Generator and async generator functions bind parameters (defaults, destructuring) at the call and defer only the
body to the first `next()`, so a bad argument throws synchronously from the call site, as in JS.
- [ ] A named function expression's name is not bound inside its own body.
- [ ] Redeclaring a function in the same scope is rejected; in JavaScript the last declaration wins.
- [ ] A line terminator between `async function` and the function name.
- [ ] Generator and async generator functions evaluate parameter defaults and destructuring at the first `next()`
rather than at the call, so their errors are not thrown synchronously.
- [x] Synchronous and async generator declarations/expressions, `yield`, and `yield*`, including lazy bodies,
`next(value)`, `return(value)`, `throw(value)`, exhaustion, promise adoption, async request ordering,
`try`/`catch`/`finally`, and sync/async iterator symbols. Async `yield*` awaits values while adapting a sync
@@ -164,8 +158,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
rejected by every synchronous consumer.
- [x] Synchronous iterator acquisition and result validation follow `IteratorClose` boundaries: consumer errors and
intentional early stops invoke `return()`, acquisition/`next()` failures do not, and an original consumer error
wins over a cleanup failure. A generator's `return()` is an intentional stop, so a `return()` that throws or
yields a non-object surfaces from it as a `TypeError`. Async iterator consumption remains limited to `for await...of` and async `yield*`.
wins over a cleanup failure. Async iterator consumption remains limited to `for await...of` and async `yield*`.
- [x] Portable generator protocol coverage is adapted from pinned Test262 cases for suspended-start, suspended-yield,
and completed states; sync and async `next`/`return`/`throw`; finally yields and completion overrides; rejected
yielded promises; mixed async request queues; sync and async `yield*` forwarding; malformed methods/results;
@@ -187,8 +180,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
on any other value throws a catchable `TypeError` naming the callee: other built-in functions such as `Number`
say `new` is unsupported and point at the plain call, user-defined functions report the constructor gap below,
and non-callable values are not constructors. Error constructors take the ES2022 options object, so
`new Error(message, { cause })` installs a non-enumerable `cause` when the option is present. `Error.isError`
is true for every Error value.
`new Error(message, { cause })` installs a non-enumerable `cause` when the option is present.
- [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`.
- [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`.
- [x] Bitwise operators: `&`, `|`, `^`, `~`, `<<`, `>>`, and `>>>`.
@@ -199,14 +191,13 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] Plain, arithmetic, bitwise, and logical assignment operators.
- [x] Property deletion on plain data objects and arrays, including computed and optional forms; deleting an array index
creates a hole without changing its length. Deleting a non-configurable property (`length`) or
assigning a read-only one (`Math.PI`, `fn.name`) throws a `TypeError`, as in strict mode. `delete` of a
non-reference (`delete 0`, `delete f()`) evaluates the operand and is `true`; `delete x` on a variable throws.
assigning a read-only one (`Math.PI`, `fn.name`) throws a `TypeError`, as in strict mode.
- [ ] Operators, `switch` discriminants, template interpolation, and coercion helpers such as `String` and `isNaN`
applied to functions and namespaces; JavaScript coerces them, the interpreter rejects non-data operands.
- [ ] ToPrimitive on object operands: operators, `Error(message)`, `Date` arguments, and `parseInt` radix should call
`valueOf`/`toString` in spec order and surface their throws.
- [x] Property keys follow ToPropertyKey: `x[null]`, `x[true]`, and objects (via their built-in string form) become
string keys.
- [ ] Property keys follow ToPropertyKey: `x[null]`, `x[true]`, and objects (via `toString`) become string keys; only
strings and numbers are accepted.
## Promises and tools
@@ -242,10 +233,6 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
resolving with the promise itself rejects with a `TypeError`. Resolver callables work anywhere callbacks are
accepted, including `.then`/`.catch` handlers and collection callbacks, and vanish at the data boundary like
any function.
- [x] `Promise.withResolvers()`: the same promise and resolver callables as the constructor, as a `{ promise, resolve,
reject }` object.
- [x] `Promise.try(fn, ...args)`: calls `fn` synchronously; a throw rejects, a return fulfils, and a returned promise or
thenable is adopted.
- [x] Recursive assimilation of objects with an own callable `then` field across `Promise.resolve`, combinators,
constructors, reactions, `finally`, `await`, and async returns. Thenable methods run deferred, receive
first-call-wins resolve/reject functions, and ignore throws after settlement. Inherited/accessor `then` fields
@@ -266,9 +253,7 @@ reject }` object.
not construct.
- [x] `Object()` and `new Object()` return `{}` for nullish arguments and pass objects through unchanged;
primitive wrapper objects (`Object(1)`) are rejected explicitly.
- [x] Computed property names and object spread. Any value works as a key (ToPropertyKey): strings, numbers, and the
two confined symbols as themselves, everything else as its string form (`o[null]` is `o["null"]`, `o[{}]` is
`o["[object Object]"]`), in reads, writes, literals, `in`, and destructuring.
- [x] Computed property names and object spread.
- [x] `Object.keys`, `Object.values`, `Object.entries`, `Object.hasOwn`, `Object.assign`, and `Object.fromEntries`, with
synchronous iterator support for `fromEntries`. Sources follow ToObject: strings enumerate by index, other
primitives and wrappers contribute nothing, and `null`/`undefined` throw. `Object.assign` accepts array
@@ -285,9 +270,8 @@ reject }` object.
- [x] `Object.is` for supported data values.
- [x] `Object.groupBy` over finite collections and custom synchronous iterators/generators, with string-key coercion
and plain-object results.
- [x] `Object.prototype` methods on values: `toString` (`"[object Array]"`, `"[object Map]"`, `"[object Promise]"`, and so
on for every built-in kind, as JS reports through `Symbol.toStringTag`), `toLocaleString` (calls the value's
`toString`, as in JS), `valueOf`, `hasOwnProperty`, `isPrototypeOf`, and `propertyIsEnumerable`.
- [x] `Object.prototype` methods on values: `toString` (`"[object Array]"`), `toLocaleString`, `valueOf`,
`hasOwnProperty`, `isPrototypeOf`, and `propertyIsEnumerable`.
## Arrays
@@ -301,14 +285,9 @@ reject }` object.
`lastIndexOf`.
- [x] Aggregation: `reduce` and `reduceRight`.
- [x] Ordering: `sort`, `toSorted`, `reverse`, and `toReversed`.
- [x] Access/copying: `at`, `slice`, `concat`, `flat`, `with`, `join`, and `toLocaleString` (each element's
`toLocaleString`, holes and nullish elements as empty strings).
- [x] Access/copying: `at`, `slice`, `concat`, `flat`, `with`, and `join`.
- [x] Mutation: `push`, `pop`, `shift`, `unshift`, `splice`, `fill`, and `copyWithin`.
- [x] `keys`, `values`, `entries`, and `[Symbol.iterator]` (the same function as `values`) return live iterator objects
with `next()` and `[Symbol.iterator]`, as in JS. Iterator objects are opaque references: they print as
`[opaque reference]`, serialize to `{}`, and cannot be passed to extensions. Every built-in collection iterator
shares one prototype. JavaScript gives each collection its own; the difference is not observable here because
`Object.getPrototypeOf` is not exposed.
- [x] Materialized iteration helpers: `keys`, `values`, and `entries` return arrays rather than iterators.
- [x] `length`, numeric indexing, index assignment, spread, and `for...of`.
- [x] The `thisArg` argument of `Array.from` is accepted and ignored, like JS arrows.
- [x] `Array.prototype.toSpliced`.
@@ -319,17 +298,15 @@ reject }` object.
- [x] Assigning `length` to truncate or extend an array; invalid lengths throw `RangeError`.
- [x] Non-index own properties on arrays (`arr.foo = 1`, `arr.constructor = null`). They are excluded from the JSON
form, like `JSON.stringify`.
- [x] Numeric arguments coerce as in JS (ToIntegerOrInfinity): `indexOf(x, "1")`, `slice("1", "3")`, `at(null)`,
`flat(1.9)`, `with(1.5, v)`, `Math.max("3", "2")`, `parseInt("11", "2")`, `(1.5).toFixed("2")`,
`String.fromCharCode("65")`, and the Uint8Array equivalents. `join(sep)` and `JSON.parse(text)` apply ToString
(`join(null)` is `"1null2"`, `JSON.parse(123)` is `123`). `Array.from({ length: "2" })` applies ToLength; a
promise source still throws with an `await` hint rather than JS's silent `[]`. A program object's own
`valueOf`/`toString` is not consulted yet (see ToPrimitive above).
- [ ] Argument coercion for `indexOf`, `lastIndexOf`, `includes`, `fill`, `flat`, `copyWithin`, and the `join`
separator: JavaScript applies ToIntegerOrInfinity/ToString (including `valueOf`, strings, and `undefined`), the
interpreter requires numbers and strings; `includes()`/`indexOf()` with no argument should search for
`undefined`.
- [ ] Iterator objects from `keys`, `values`, and `entries` with a live `next()`.
## Strings
- [x] Case/normalization: `toLowerCase`, `toUpperCase`, `normalize`, and the `toLocaleLowerCase`/`toLocaleUpperCase`
aliases, which ignore their locale argument and apply the default Unicode casing.
- [x] Case/normalization: `toLowerCase`, `toUpperCase`, `normalize`.
- [x] Trimming: `trim`, `trimStart`, and `trimEnd`, plus the Annex B `trimLeft` and `trimRight` aliases.
- [x] Searching/tests: `includes`, `startsWith`, `endsWith`, `indexOf`, `lastIndexOf`, and `search`.
- [x] Slicing/access: `slice`, `substring`, Annex B `substr`, `at`, `charAt`, `charCodeAt`, and `codePointAt`.
@@ -337,7 +314,7 @@ reject }` object.
- [x] Regular-expression integration: `match`, materialized `matchAll`, `replace`, `replaceAll`, `split`, and `search`.
- [x] `localeCompare`; locale and options arguments are currently ignored.
- [x] `isWellFormed` and `toWellFormed`.
- [x] `toString`, `length`, numeric indexing, spread, `for...of`, and `[Symbol.iterator]` by Unicode code point.
- [x] `toString`, `length`, numeric indexing, spread, and `for...of` by Unicode code point.
- [x] Static `String.fromCharCode` and `String.fromCodePoint`.
- [x] Native argument coercion for supported String methods; for example, `includes(1)` and `slice("1")` coerce like
native JS, `split(undefined)` returns the whole string, and `includes`/`startsWith`/`endsWith` reject regular
@@ -353,8 +330,7 @@ reject }` object.
- [x] Coercion functions: `Number`, `parseInt`, and `parseFloat`.
- [x] Number predicates/parsers: `Number.isInteger`, `Number.isFinite`, `Number.isNaN`, `Number.isSafeInteger`,
`Number.parseInt`, and `Number.parseFloat`.
- [x] Number formatting: `toFixed`, `toPrecision`, `toExponential`, `toString`, `valueOf`, and `toLocaleString`, which
always formats as `en-US` (`"1,234.5"`) so output does not depend on the host.
- [x] Number formatting: `toFixed`, `toPrecision`, `toExponential`, `toString`, and `valueOf`.
- [x] Number constants: `MAX_SAFE_INTEGER`, `MIN_SAFE_INTEGER`, `MAX_VALUE`, `MIN_VALUE`, `EPSILON`, `NaN`,
`POSITIVE_INFINITY`, and `NEGATIVE_INFINITY`.
- [x] Math constants: `PI`, `E`, `LN2`, `LN10`, `LOG2E`, `LOG10E`, `SQRT2`, and `SQRT1_2`.
@@ -367,8 +343,7 @@ reject }` object.
use their epoch time) and reject opaque runtime references as data errors.
- [x] Unknown static members on global namespaces and on `Number`/`String`/the coercion functions read as `undefined`
for feature detection. Calling any undefined value reports a native-style `TypeError` naming the callee, for
example `Math.sum is not a function.` or `search(...).catch is not a function.` Unknown `Promise` statics keep
their descriptive error.
example `Math.sum is not a function.` Unknown `Promise` statics keep their descriptive error.
- [x] `Math.sumPrecise` over finite collections and custom synchronous iterators/generators, rejecting non-number
elements without coercion.
- [x] Global coercing `isFinite` and `isNaN`; opaque runtime references reject as data errors, like `Number(...)`.
@@ -382,8 +357,7 @@ reject }` object.
- [x] `JSON.stringify` function and array replacers. Function replacers receive `(key, value)` in preorder, including
the root, but no `this` holder. Array replacers preserve requested property order, deduplicate names, coerce
number primitives, and ignore non-string/non-number entries. Primitive wrapper entries remain unsupported.
- [x] Captured `console.log`, `console.info`, `console.debug`, `console.warn`, and `console.error`. An Error prints as
`Error.prototype.toString` would show it (`Error: boom`), wherever it appears in the logged value.
- [x] Captured `console.log`, `console.info`, `console.debug`, `console.warn`, and `console.error`.
- [x] Captured `console.dir` and `console.table`.
## Date
@@ -403,8 +377,6 @@ reject }` object.
`TimeClip` behavior.
- [x] `Date.prototype.toUTCString` and its `toGMTString` alias.
- [x] `toDateString` and `toTimeString` in the host's local timezone.
- [x] `toLocaleString`, `toLocaleDateString`, and `toLocaleTimeString` always format as `en-US` in UTC
(`"1/1/1970, 12:00:00 AM"`) so output does not depend on the host.
- [x] Native one-argument Date coercion for supported values, including booleans, null, arrays, and plain objects.
- [ ] Date setters and multi-argument construction coerce object arguments through `valueOf`/`toString` and surface
their throws.
@@ -426,30 +398,14 @@ reject }` object.
- [x] Match `indices` metadata for the `d` flag, including named groups on `exec`, `match`, and `matchAll` results.
- [x] `RegExp.escape`.
## Iterator
- [x] `Iterator.prototype.map`, `filter`, `take`, `drop`, and `flatMap` on any iterator or generator: lazy, one source
step per result, closing the source when a callback throws, on early `return()`, or when `for...of` or
destructuring finishes with it early. Once done or closed a helper stays done, and a callback that re-enters its
own helper is a `TypeError`. `take`/`drop` coerce their count and reject `NaN` or negative counts with a
`RangeError`; `flatMap` callbacks must return an iterable or iterator, not a string.
- [x] `Iterator.prototype.reduce`, `toArray`, `forEach`, `some`, `every`, and `find`, closing the source on early exit.
- [x] `Iterator.from(value)` returns iterators and generators as they are, and wraps strings, iterables, and objects
with a `next` method. `Iterator` itself is abstract: calling or constructing it is a `TypeError`.
- [x] Helpers and `Iterator.from` wrappers have `return()`; collection iterators (`array.values()`) do not, as in JS,
so an early exit from them leaves them where they were.
- [ ] `Iterator.concat`, `Iterator.zip`, and `Iterator.zipKeyed` (stage 3 proposals).
## Map and Set
- [x] Static `Map.groupBy` over finite collections and custom synchronous iterators/generators, preserving key identity.
- [x] `new Map()` from synchronous iterables of entries.
- [x] Map `get`, `set`, `has`, `delete`, `clear`, `size`, `forEach`, `getOrInsert`, and `getOrInsertComputed`.
`forEach` is live: entries deleted during the walk are skipped and entries added are visited, as in JS.
- [x] Map `get`, `set`, `has`, `delete`, `clear`, `size`, and `forEach`.
- [x] `new Set()` from synchronous iterables.
- [x] Set `add`, `has`, `delete`, `clear`, `size`, and `forEach`.
- [x] Live `keys`, `values`, `entries`, and `[Symbol.iterator]` iterators for Map and Set; a Set-like operand's `keys()`
may return any iterator or an array.
- [x] Materialized `keys`, `values`, and `entries` arrays for Map and Set.
- [x] Spread, `for...of`, `Array.from`, and `Object.fromEntries` integration.
- [x] Map and Set values serialize to `{}` at host/JSON boundaries.
- [x] Set composition and relation methods: `union`, `intersection`, `difference`, `symmetricDifference`, `isSubsetOf`,
@@ -465,9 +421,8 @@ reject }` object.
- [x] Writable URL fields except `origin`.
- [x] `new URLSearchParams()` from query strings, data objects, synchronous iterables of pairs, and URLSearchParams.
- [x] URLSearchParams `append`, `delete`, `get`, `getAll`, `has`, `set`, `sort`, `forEach`, `keys`, `values`,
`entries`, `[Symbol.iterator]`, `toString`, and `size`.
- [x] URL values are their href in `JSON.stringify` and at the host boundary. URLSearchParams are `{}` in
`JSON.stringify` and their query string at the host boundary.
`entries`, `toString`, and `size`.
- [x] URL values serialize to their href; URLSearchParams serialize to `{}`.
## Uint8Array
@@ -479,8 +434,7 @@ with a hint to encode as text first (`TextDecoder`, `toBase64`, `toHex`).
- [x] Index reads and writes with JS byte semantics: values wrap modulo 256, out-of-range writes are ignored, indexes
cannot be deleted. `length` is a prototype accessor, so `Object.keys` lists only indexes.
- [x] `at`, `slice`, `subarray` (a view on the same bytes), `set`, `fill`, `reverse`, `indexOf`, `lastIndexOf`,
`includes`, `join`, `toString`, `toBase64`, `toHex`, and live `keys`, `values`, `entries`, and `[Symbol.iterator]`
iterators.
`includes`, `join`, `toString`, `toBase64`, `toHex`, and materialized `keys`, `values`, and `entries` arrays.
- [x] Spread, destructuring, `for...of`, `yield*`, `Array.from`, and `new Set(bytes)`. `Array.isArray` is false.
- [x] String coercion joins with commas; `JSON.stringify` gives `{"0":1,...}`; `console.log` prints
`Uint8Array(n) [...]`.
@@ -496,8 +450,8 @@ with a hint to encode as text first (`TextDecoder`, `toBase64`, `toHex`).
`fatal` and `ignoreBOM` options; `decode` takes a Uint8Array or nothing.
- [x] `new Headers()` from records, synchronous iterables of pairs, and Headers, wrapping the host's `Headers`: names
fold to lowercase, values are normalized and combined, and invalid names or values throw a `TypeError`.
- [x] Headers `append`, `delete`, `get`, `getSetCookie`, `has`, `set`, `forEach`, `keys`, `values`, `entries`, and
`[Symbol.iterator]`; iteration is live and sorted by name, with `set-cookie` values kept apart.
- [x] Headers `append`, `delete`, `get`, `getSetCookie`, `has`, `set`, `forEach`, `keys`, `values`, and `entries`;
iteration is live and sorted by name, with `set-cookie` values kept apart.
- [x] Headers serialize to a `{ name: value }` object in JSON, in results, and in tool arguments.
- [ ] `Request`, `Response`, and `Blob`.
- [ ] `crypto.subtle` and `TextDecoder` streaming or non-UTF-8 encodings.
@@ -512,8 +466,7 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
- [x] Every value crossing in either direction is converted, never shared: plain objects and arrays are copied,
`Date`, `RegExp`, `URL`, `URLSearchParams`, `Headers`, `Map`, `Set`, and `Uint8Array` become fresh copies with
their contents converted (a host `ArrayBuffer` comes in as a `Uint8Array`; other typed arrays cannot come out),
errors cross as errors with their name, message, `cause`, and own enumerable data, and a `__proto__` key is
dropped. Functions, generators,
errors cross as errors with their name and message, and a `__proto__` key is dropped. Functions, generators,
un-awaited promises, and symbols cannot be passed in; a class instance, a symbol, or a BigInt cannot come out.
- [x] A host function inside a result becomes a program function whose calls cross the same way, so a result can
carry methods (`res.json()`) whose host closures keep the host state. Diagnostics name it by its path
@@ -538,10 +491,8 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
- [x] `AggregateError` with the `(errors, message?)` signature and an own `errors` array, constructed directly or by
an all-rejected `Promise.any`; direct construction accepts custom synchronous iterators and generators.
- [x] Error `name`/`message`, error inheritance through `instanceof`, and plain-data serialization. `message` is an own
non-enumerable property and `name` is inherited, as in JS, so `Object.keys(err)` is `[]` for a plain error. The
result boundary still emits `{ name, message, ...own enumerable }`, so a field such as `code` crosses. `cause` is
non-enumerable: an extension Error carries it, and this JSON form does not. Errors have no `stack`; the diagnostic
carries a 1-based line and column in the submitted source instead.
non-enumerable property and `name` is inherited, as in JS, so `Object.keys(err)` is `[]` while the host still
receives `{ name, message }`. Errors have no `stack`; the diagnostic carries the source location instead.
- [x] `instanceof` against any constructor with a `prototype`, including every built-in and `Function`.
- [x] Catchable user throws, runtime failures raised during interpreted evaluation, awaited tool failures, and awaited
tool-call-limit failures; parse/compile failures, cooperative timeout, and output bounding remain outside program
@@ -550,9 +501,8 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
short orientation to the supported subset; this matrix is the full reference.
- [x] Model-visible host failure messages and underlying causes, including output-validation errors.
- [x] Caught errors do not distinguish user throws, interpreter failures, and tool failures; a program sees one
Error-shaped value in `catch`, rejection handlers, and `Promise.allSettled` reasons. It always has `name` and
`message`, plus `cause` and own data when the failure carried them. This is deliberate: the program should
handle a failure the same way regardless of where it originated.
Error-shaped value with `name` and `message` in `catch`, rejection handlers, and `Promise.allSettled` reasons.
This is deliberate: the program should handle a failure the same way regardless of where it originated.
- [x] Failures raised by the interpreter are `TypeError`s unless JavaScript names them otherwise (`RangeError`,
`ReferenceError`, `SyntaxError`, `URIError`), so `e instanceof TypeError` and `e.constructor === TypeError`
hold. Unsupported syntax reached at runtime is a `SyntaxError`; awaited tool failures stay plain `Error`.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/codemode",
"version": "2.0.12",
"version": "2.0.10",
"description": "Effect-native confined code execution over schema-described tools",
"type": "module",
"license": "MIT",
+5 -6
View File
@@ -15,7 +15,6 @@ import {
Obj,
PromiseObj,
record,
type Value,
SetObj,
URLSearchParamsObj,
HeadersObj,
@@ -24,14 +23,14 @@ import { typeofValue } from "./interpreter/references.js"
export type Json = Schema.Json
type Replacer<R> = (args: Array<Value>) => Effect.Effect<Value, unknown, R>
type Replacer<R> = (args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
/**
* What `JSON.stringify` would serialize for a program value, as host JSON: `toJSON` is honored, functions and
* `undefined` vanish, non-finite numbers become null, and everything else is copied. Two departures from JS
* so a mistake is not a silent `{}`: an Error serializes as `{ name, message, ...own }`, and a promise throws.
*/
export const toJson = <R>(ctx: Interpreter<R>, value: Value, replacer?: Replacer<R>) =>
export const toJson = <R>(ctx: Interpreter<R>, value: unknown, replacer?: Replacer<R>) =>
walk(ctx, value, replacer, false)
/**
@@ -39,11 +38,11 @@ export const toJson = <R>(ctx: Interpreter<R>, value: Value, replacer?: Replacer
* awaited, a Set crosses as an array, a URLSearchParams as its query string, a Uint8Array asks to be encoded as
* text first, and a `__proto__` key is dropped so host code can never receive one.
*/
export const toBoundary = <R>(ctx: Interpreter<R>, value: Value) => walk(ctx, value, undefined, true)
export const toBoundary = <R>(ctx: Interpreter<R>, value: unknown) => walk(ctx, value, undefined, true)
const walk = <R>(
ctx: Interpreter<R>,
value: Value,
value: unknown,
replacer: Replacer<R> | undefined,
boundary: boolean,
): Effect.Effect<Json | undefined, unknown, R> => {
@@ -105,7 +104,7 @@ const walk = <R>(
}
/** Host JSON as program values: objects and arrays are copied, primitives pass through. */
export const fromJson = <R>(ctx: Interpreter<R>, value: Json | undefined): Value => {
export const fromJson = <R>(ctx: Interpreter<R>, value: unknown): unknown => {
if (value === null || typeof value !== "object") return value
if (Array.isArray(value))
return new Arr(
+22 -40
View File
@@ -1,35 +1,24 @@
import { Cause, Effect, Exit } from "effect"
import { Effect, Exit } from "effect"
import { coerceToNumber, coerceToString } from "../stdlib/value.js"
import type { Interpreter } from "./interpreter.js"
import { primitivePrototype } from "./intrinsics.js"
import { GeneratorReturn, typeError } from "./model.js"
import {
Callable,
get,
Native,
DateObj,
Obj,
coerceToNumber,
coerceToString,
type Cursor,
type Value,
} from "./objects.js"
import { typeError } from "./model.js"
import { Callable, get, Native, DateObj, Obj } from "./objects.js"
import { typeofValue } from "./references.js"
/** IteratorClose: a consumer failure closes the iterator and wins over any close failure, except that a generator's
* return() is a return completion, so a failing close wins over it, as after `break`. */
export type IteratorCursor<R> = {
readonly next: Effect.Effect<{ readonly done: boolean; readonly value: unknown }, unknown, R>
readonly close: Effect.Effect<void, unknown, R>
}
export const preserveConsumerError = <A, R>(
close: Cursor<R>["close"],
cursor: IteratorCursor<R>,
effect: Effect.Effect<A, unknown, R>,
): Effect.Effect<A, unknown, R> =>
Effect.flatMap(Effect.exit(effect), (exit) => {
if (Exit.isSuccess(exit)) return Effect.succeed(exit.value)
return Effect.flatMap(Effect.exit(close), (closed) => {
if (!Exit.isSuccess(closed) && Cause.squash(exit.cause) instanceof GeneratorReturn) {
return Effect.failCause(closed.cause)
}
return Effect.failCause(exit.cause)
})
})
Effect.flatMap(Effect.exit(effect), (exit) =>
Exit.isSuccess(exit)
? Effect.succeed(exit.value)
: Effect.andThen(Effect.exit(cursor.close), Effect.failCause(exit.cause)),
)
/**
* ToPrimitive: calls `valueOf`/`toString` in hint order and returns the first primitive result. Dates treat the
@@ -37,9 +26,9 @@ export const preserveConsumerError = <A, R>(
*/
export const toPrimitive = <R>(
ctx: Interpreter<R>,
value: Value,
value: unknown,
hint: "number" | "string" | "default",
): Effect.Effect<Value, unknown, R> => {
): Effect.Effect<unknown, unknown, R> => {
if (!(value instanceof Obj)) return Effect.succeed(value)
const asString = hint === "string" || (hint === "default" && value instanceof DateObj)
const order = asString ? ["toString", "valueOf"] : ["valueOf", "toString"]
@@ -54,31 +43,24 @@ export const toPrimitive = <R>(
})
}
/** Invoke(value, name): calls the method the value would find through its prototype. */
export const invoke = <R>(ctx: Interpreter<R>, value: Value, name: string, label: string) => {
const target = value instanceof Obj ? value : primitivePrototype(ctx.builtins, value)
if (target === undefined) throw typeError(`${label} called on null or undefined.`)
return ctx.call(get(target, name), value, [])
}
export const toPrimitiveString = <R>(ctx: Interpreter<R>, value: Value) =>
export const toPrimitiveString = <R>(ctx: Interpreter<R>, value: unknown) =>
Effect.map(toPrimitive(ctx, value, "string"), coerceToString)
export const toPrimitiveNumber = <R>(ctx: Interpreter<R>, value: Value) =>
export const toPrimitiveNumber = <R>(ctx: Interpreter<R>, value: unknown) =>
Effect.map(toPrimitive(ctx, value, "number"), coerceToNumber)
// The single acceptance list for callbacks: collections, sort, string replacers,
// Array.from mappers, and promise reactions all admit exactly these callables.
// Admission means dispatchable, not necessarily invocable: new-requiring
// constructors pass the gate and throw a TypeError on call, like JS.
export const isSupportedCallback = (value: Value): value is Callable =>
export const isSupportedCallback = (value: unknown): value is Callable =>
value instanceof Callable && !(value instanceof Native && !value.callback)
export const applyCollectionCallback = <R>(
ctx: Interpreter<R>,
callback: Value,
callback: unknown,
name: string,
): ((args: Array<Value>) => Effect.Effect<Value, unknown, R>) => {
): ((args: Array<unknown>) => Effect.Effect<unknown, unknown, R>) => {
if (!isSupportedCallback(callback)) {
if (typeofValue(callback) === "function") {
throw typeError(
+7 -19
View File
@@ -6,21 +6,10 @@ import { type AstNode, formatLocation, PendingThrow, Throw, sourceLocation, type
import { containsRuntimeReference } from "./references.js"
import { createErrorValue, type ErrorType, isErrorType } from "./intrinsics.js"
import { constructor, methods, prototypeFrom, receiver } from "./native.js"
import {
type Callable,
define,
get,
has,
hidden,
type Native,
Arr,
ErrorObj,
Obj,
coerceToString,
type Value,
} from "./objects.js"
import { type Callable, define, get, has, hidden, type Native, Arr, ErrorObj, Obj } from "./objects.js"
import type { Interpreter } from "./interpreter.js"
import { formatValue } from "../stdlib/console.js"
import { coerceToString } from "../stdlib/value.js"
export const normalizeError = (error: unknown): Diagnostic => {
if (error instanceof PendingThrow) {
@@ -97,7 +86,7 @@ export const locate = (error: unknown, node?: AstNode): unknown => {
}
/** The program value a handler receives for a failure; one failure always yields the same value. */
export const materialize = <R>(ctx: Interpreter<R>, thrown: unknown): Value => {
export const materialize = <R>(ctx: Interpreter<R>, thrown: unknown): unknown => {
if (thrown instanceof Throw) return thrown.value
const builtins = ctx.builtins
if (thrown instanceof PendingThrow) {
@@ -124,7 +113,7 @@ const errorToString = (self: Obj): string => {
export const createAggregateErrorValue = <R>(
ctx: Interpreter<R>,
errors: Array<Value>,
errors: Array<unknown>,
message: string,
proto: Obj = ctx.builtins.AggregateError,
) => {
@@ -135,13 +124,13 @@ export const createAggregateErrorValue = <R>(
const constructAggregateErrorValue = <R>(
ctx: Interpreter<R>,
args: Array<Value>,
args: Array<unknown>,
proto: Obj,
): Effect.Effect<ErrorObj, unknown, R> =>
Effect.gen(function* () {
const cursor = yield* ctx.iterate(args[0])
if (cursor === undefined) throw typeError("new AggregateError(...) expects a synchronous iterable of errors.")
const errors: Array<Value> = []
const errors: Array<unknown> = []
while (true) {
const step = yield* cursor.next
if (step.done) {
@@ -155,7 +144,7 @@ const constructAggregateErrorValue = <R>(
export const errorGlobal = <R>(type: ErrorType, ctx: Interpreter<R>) => {
const builtins = ctx.builtins
const prototype = builtins[type]
const construct = (args: Array<Value>, newTarget: Callable) => {
const construct = (args: Array<unknown>, newTarget: Callable) => {
const proto = prototypeFrom(newTarget, prototype)
const created =
type === "AggregateError"
@@ -179,7 +168,6 @@ export const errorGlobal = <R>(type: ErrorType, ctx: Interpreter<R>) => {
methods(builtins, prototype, [
["toString", 0, (thisValue) => errorToString(receiver(Obj, thisValue, "Error.prototype.toString"))],
])
methods(builtins, ctor, [["isError", 1, (_, args) => args[0] instanceof ErrorObj]])
}
return ctor
}
+1 -2
View File
@@ -4,7 +4,6 @@ import type { DataValue, Diagnostic, ResolvedExecutionLimits, Result } from "../
import { toBoundary } from "../data.js"
import { ToolRuntime } from "../tool-runtime.js"
import { normalizeError } from "./errors.js"
import type { Value } from "./objects.js"
import { createBuiltins } from "./intrinsics.js"
import { Pending } from "./promises.js"
import { Interpreter } from "./interpreter.js"
@@ -14,7 +13,7 @@ export const executeProgram = <R>(
prepared: ToolRuntime.Prepared<R>,
limits: ResolvedExecutionLimits,
hooks: ToolRuntime.Hooks<R>,
globals?: (ctx: Interpreter<R>) => ReadonlyArray<readonly [string, Value]>,
globals?: (ctx: Interpreter<R>) => ReadonlyArray<readonly [string, unknown]>,
): Effect.Effect<Result, never, R> => {
if (code.trim().length === 0) {
return Effect.succeed({
+39 -13
View File
@@ -1,5 +1,6 @@
import { Effect } from "effect"
import type { Extension } from "../extension.js"
import { coerceToString } from "../stdlib/value.js"
import { type ExtensionInvocation, hooked } from "../tool-runtime.js"
import type { Interpreter } from "./interpreter.js"
import { createErrorValue, isErrorType } from "./intrinsics.js"
@@ -7,7 +8,9 @@ import { MAX_VALUE_DEPTH } from "./limits.js"
import { PendingThrow, Throw, typeError } from "./model.js"
import { fn } from "./native.js"
import {
Callable,
define,
entries,
get,
has,
hidden,
@@ -16,17 +19,17 @@ import {
Bytes,
DateObj,
ErrorObj,
HeadersObj,
GeneratorObj,
MapObj,
Obj,
PromiseObj,
RegExpObj,
SetObj,
URLObj,
URLSearchParamsObj,
coerceToString,
type Value,
HeadersObj,
} from "./objects.js"
import { describeValue, isOpaque } from "./references.js"
import { describeValue } from "./references.js"
/**
* The global bindings of one run's extensions. Everything crossing the boundary is converted: plain data and
@@ -36,18 +39,34 @@ import { describeValue, isOpaque } from "./references.js"
export const extensionGlobals = <R>(
ctx: Interpreter<R>,
extensions: ReadonlyArray<Extension>,
): ReadonlyArray<readonly [string, Value]> => {
): ReadonlyArray<readonly [string, unknown]> => {
const builtins = ctx.builtins
const toHost = (value: Value, label: string, depth = 0, seen = new Set<object>()): unknown => {
const toHost = (value: unknown, label: string, depth = 0, seen = new Set<object>()): unknown => {
if (depth > MAX_VALUE_DEPTH) throw typeError(`${label} exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`)
if (isPrimitive(value)) return value
if (!(value instanceof Obj) || isOpaque(value)) {
if (value === null || typeof value !== "object") {
if (isPrimitive(value)) return value
throw typeError(`${label} contains ${describeValue(value)}, which cannot be passed to an extension.`)
}
if (value instanceof Bytes) return new Uint8Array(value.bytes)
if (value instanceof DateObj) return new Date(value.time)
if (value instanceof RegExpObj) return new RegExp(value.regex.source, value.regex.flags)
if (value instanceof URLObj) return new URL(value.url.href)
if (value instanceof URLSearchParamsObj) return new URLSearchParams(value.params)
if (value instanceof HeadersObj) return new Headers(value.headers)
const next = (item: unknown) => toHost(item, label, depth + 1, seen)
if (value instanceof MapObj) return new Map([...value.map].map(([key, item]) => [next(key), next(item)]))
if (value instanceof SetObj) return new Set([...value.set].map(next))
if (
!(value instanceof Obj) ||
value instanceof Callable ||
value instanceof GeneratorObj ||
value instanceof PromiseObj
) {
throw typeError(`${label} contains ${describeValue(value)}, which cannot be passed to an extension.`)
}
if (seen.has(value)) throw typeError(`${label} contains a circular value.`)
seen.add(value)
const next = (item: Value) => toHost(item, label, depth + 1, seen)
if (value instanceof ErrorObj) {
const name = coerceToString(get(value, "name"))
const message = get(value, "message")
@@ -68,12 +87,19 @@ export const extensionGlobals = <R>(
seen.delete(value)
return copied
}
const copied = value.toHost(next)
const copied =
value instanceof Arr
? value.items.map(next)
: Object.fromEntries(
entries(value)
.filter(([key]) => key !== "__proto__")
.map(([key, item]) => [key, next(item)]),
)
seen.delete(value)
return copied
}
const fromHost = (value: unknown, label: string, depth = 0, seen = new Set<object>()): Value => {
const fromHost = (value: unknown, label: string, depth = 0, seen = new Set<object>()): unknown => {
if (depth > MAX_VALUE_DEPTH) throw typeError(`${label} exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`)
if (isPrimitive(value)) return value
if (typeof value === "function") return wrap(value, label)
@@ -172,7 +198,7 @@ export const extensionGlobals = <R>(
*/
const uncrossed = new Set(["stack", "constructor", "toString", "__proto__"])
const left = Symbol("left behind")
const crossing = <T>(convert: () => T): T | typeof left => {
const crossing = (convert: () => unknown): unknown => {
try {
return convert()
} catch (reason) {
@@ -191,7 +217,7 @@ const hostErrors = new Map<string, ErrorConstructor>([
])
// The primitives the interpreter operates on; symbols and BigInts are not among them.
const isPrimitive = (value: unknown): value is string | number | boolean | null | undefined =>
const isPrimitive = (value: unknown): boolean =>
value === null ||
value === undefined ||
typeof value === "string" ||
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import { fn, type Method, methods, receiver } from "./native.js"
import { AsyncIteratorSymbol, type GeneratorRequestKind, IteratorSymbol } from "./model.js"
import { define, hidden, GeneratorObj, type Value } from "./objects.js"
import { define, hidden, GeneratorObj } from "./objects.js"
import type { Interpreter } from "./interpreter.js"
/** `next`/`return`/`throw` on the generator prototypes; async generators answer with promises. */
@@ -13,9 +13,9 @@ export const generatorGlobals = <R>(ctx: Interpreter<R>): void => {
const request = (kind: GeneratorRequestKind): Method => [
kind,
1,
(thisValue: Value, args: Array<Value>) => {
(thisValue: unknown, args: Array<unknown>) => {
const generator = receiver(GeneratorObj, thisValue, `${label}.prototype.${kind}`)
const requested = generator.request(kind, args[0]) as Effect.Effect<Value, unknown, R>
const requested = generator.request(kind, args[0]) as Effect.Effect<unknown, unknown, R>
return generator.asynchronous ? ctx.pending.create(requested) : requested
},
]
+2 -5
View File
@@ -1,5 +1,4 @@
import { Effect } from "effect"
import type { Value } from "./objects.js"
import { arrayGlobal } from "../stdlib/array.js"
import { textDecoderGlobal, textEncoderGlobal, uint8ArrayGlobal } from "../stdlib/bytes.js"
import { mapGlobal, setGlobal } from "../stdlib/collections.js"
@@ -13,7 +12,6 @@ import { regexpGlobal } from "../stdlib/regexp.js"
import { stringGlobal } from "../stdlib/string.js"
import { uriGlobal, urlGlobal, urlSearchParamsGlobal } from "../stdlib/url.js"
import { headersGlobal } from "../stdlib/headers.js"
import { iteratorGlobals } from "../stdlib/iterator.js"
import { coercion } from "../stdlib/value.js"
import { base64Global, cryptoGlobal } from "../stdlib/web.js"
import { ToolReference } from "../tool-runtime.js"
@@ -52,7 +50,7 @@ const symbolGlobal = <R>(ctx: Interpreter<R>) => {
return symbol
}
type Factory = <R>(ctx: Interpreter<R>) => Value
type Factory = <R>(ctx: Interpreter<R>) => unknown
// A table rather than a list so the names are known before any runtime exists.
const table: Record<string, Factory> = {
@@ -70,7 +68,6 @@ const table: Record<string, Factory> = {
console: (ctx) => consoleGlobal(ctx),
Promise: (ctx) => promiseGlobal(ctx),
Symbol: (ctx) => symbolGlobal(ctx),
Iterator: (ctx) => iteratorGlobals(ctx),
Number: (ctx) => numberGlobal(ctx),
String: (ctx) => stringGlobal(ctx),
Boolean: (ctx) => booleanGlobal(ctx),
@@ -102,7 +99,7 @@ const table: Record<string, Factory> = {
export const globalNames: ReadonlySet<string> = new Set(Object.keys(table))
/** The immutable global bindings of every program, in declaration order. */
export const globals = <R>(ctx: Interpreter<R>): ReadonlyArray<readonly [string, Value]> => {
export const globals = <R>(ctx: Interpreter<R>): ReadonlyArray<readonly [string, unknown]> => {
generatorGlobals(ctx)
return Object.entries(table).map(([name, factory]) => [name, factory(ctx)] as const)
}
+217 -213
View File
@@ -32,7 +32,6 @@ import type {
Statement,
Super,
SwitchStatement,
Literal,
TemplateLiteral,
ThrowStatement,
TryStatement,
@@ -63,33 +62,32 @@ import {
} from "./model.js"
import { checkStringLength } from "./limits.js"
import { locate, materialize } from "./errors.js"
import { type Builtins, primitivePrototype } from "./intrinsics.js"
import type { Builtins } from "./intrinsics.js"
import { globals } from "./globals.js"
import {
assign,
Callable,
define,
get,
hostCursor,
IteratorObj,
type Cursor,
has,
hidden,
hasPrototype,
keys,
Native,
parseArrayIndex,
Arr,
Bytes,
DateObj,
Fn,
GeneratorObj,
MapObj,
Obj,
PromiseObj,
SetObj,
URLSearchParamsObj,
HeadersObj,
record,
remove,
set,
coerceToNumber,
coerceToString,
type Value,
} from "./objects.js"
import { preserveConsumerError } from "./callback.js"
import { Pending, resolvePromise, resolvePromiseValue } from "./promises.js"
@@ -97,7 +95,7 @@ import { containsOpaqueReference, describeValue, rejectCircularInsertion, typeof
import { ScopeStack } from "./scope.js"
import { constructRegExp } from "../stdlib/regexp.js"
import { enumerableSource } from "../stdlib/object.js"
import { compoundOperators } from "../stdlib/value.js"
import { coerceToNumber, coerceToString, compoundOperators } from "../stdlib/value.js"
// What a loop does with its body's result: exit with a StatementResult, or undefined to keep iterating.
// Unlabelled break ends this loop; a label the loop does not carry propagates outward.
@@ -111,36 +109,24 @@ const loopExit = (result: StatementResult, labels: ReadonlySet<string> | undefin
return undefined
}
// Native engines name the callee (`search(...).catch is not a function`), including call chains. Returns
// undefined when a link cannot be named, so a chain is either named completely or not at all.
const calleeDescription = (node: Expression | Super | undefined): string | undefined => {
if (node?.type === "Identifier") return node.name
if (node?.type === "CallExpression") {
const target = calleeDescription(node.callee)
return target === undefined ? undefined : `${target}(...)`
const calleeDescription = (callee: Expression | Super | undefined): string => {
if (callee?.type === "Identifier") return callee.name
if (callee?.type === "MemberExpression") {
const object = callee.object
const property = callee.property
const key =
!callee.computed && property.type === "Identifier"
? property.name
: property.type === "Literal" && typeof property.value === "string"
? property.value
: undefined
if (object.type === "Identifier" && key !== undefined) return `${object.name}.${key}`
}
if (node?.type !== "MemberExpression") return undefined
const property = node.property
const key =
!node.computed && property.type === "Identifier"
? property.name
: property.type === "Literal" && typeof property.value === "string"
? property.value
: undefined
if (key === undefined) return undefined
const object = calleeDescription(node.object)
return object === undefined ? undefined : `${object}.${key}`
return "The called value"
}
// OrdinaryHasInstance: walk the left operand's chain looking for the constructor's `prototype`.
// acorn types every literal as possibly a BigInt or RegExp; regex literals become RegExp objects before this is asked.
const literal = (node: Literal): Value => {
if (typeof node.value === "bigint") throw typeError("BigInt literals are not supported.", node)
if (node.value instanceof RegExp) throw unsupportedSyntax("RegExpLiteral", node)
return node.value
}
const instanceofValue = (lhs: Value, rhs: Value, node: AstNode): boolean => {
const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean => {
if (!(rhs instanceof Callable)) {
throw typeError("The right-hand side of 'instanceof' is not callable.", node)
}
@@ -237,7 +223,7 @@ const loopDeclaration = (left: VariableDeclaration | Pattern, statement: "for...
type CustomIterator = {
iterator: Obj
next: Value
next: unknown
asynchronous: boolean
}
@@ -245,13 +231,13 @@ type CustomIterator = {
type MemberReference = {
target: Obj
key: PropertyKey
receiver: Value
receiver: unknown
}
type GeneratorRequest = {
kind: GeneratorRequestKind
value: Value
response: Deferred.Deferred<Value, unknown>
value: unknown
response: Deferred.Deferred<unknown, unknown>
}
type GeneratorState = {
@@ -277,7 +263,7 @@ export class Interpreter<R> {
readonly pending: Pending<R>
readonly builtins: Builtins
readonly logs?: Array<string>
readonly globals?: (ctx: Interpreter<R>) => ReadonlyArray<readonly [string, Value]>
readonly globals?: (ctx: Interpreter<R>) => ReadonlyArray<readonly [string, unknown]>
}) {
this.tools = options.tools
this.pending = options.pending
@@ -291,31 +277,27 @@ export class Interpreter<R> {
}
}
run(program: Program): Effect.Effect<Value, unknown, R> {
run(program: Program): Effect.Effect<unknown, unknown, R> {
return this.root.run(program)
}
call(callable: Value, thisValue: Value, args: Array<Value>): Effect.Effect<Value, unknown, R> {
call(callable: unknown, thisValue: unknown, args: Array<unknown>): Effect.Effect<unknown, unknown, R> {
return this.root.call(callable, thisValue, args)
}
await(promise: PromiseObj): Effect.Effect<Value, unknown, never> {
await(promise: PromiseObj): Effect.Effect<unknown, unknown, never> {
return this.root.await(promise)
}
iterate(value: Value) {
iterate(value: unknown) {
return this.root.iterate(value)
}
iterateDirect(value: Value) {
return this.root.iterateDirect(value)
}
/** Runs one host tool: arguments cross as JSON and the result comes back as program values. */
tool(
run: (args: Array<Json | undefined>) => Effect.Effect<Json | undefined, unknown, R>,
args: Array<Value>,
): Effect.Effect<Value, unknown, R> {
args: Array<unknown>,
): Effect.Effect<unknown, unknown, R> {
const ctx = this
return Effect.gen(function* () {
const json = yield* Effect.forEach(args, (arg) => toBoundary(ctx, arg))
@@ -338,7 +320,7 @@ class Frame<R> {
private depth = 0,
) {}
run(program: Program): Effect.Effect<Value, unknown, R> {
run(program: Program): Effect.Effect<unknown, unknown, R> {
const self = this
// Keep top-level declarations separate so they can shadow builtins.
this.scopes.push()
@@ -346,7 +328,7 @@ class Frame<R> {
self.predeclareLexical(program.body)
self.hoistFunctions(program.body)
self.hoistVars(program.body)
let value: Value = undefined
let value: unknown = undefined
for (const [index, statement] of program.body.entries()) {
if (index === program.body.length - 1 && statement.type === "ExpressionStatement") {
value = yield* self.evaluateExpression(statement.expression)
@@ -371,12 +353,15 @@ class Frame<R> {
}
// Fork at the call site so admission and hooks occur when the call is made.
private createToolCallPromise(path: ReadonlyArray<string>, args: Array<Value>): Effect.Effect<PromiseObj, never, R> {
private createToolCallPromise(
path: ReadonlyArray<string>,
args: Array<unknown>,
): Effect.Effect<PromiseObj, never, R> {
return this.ctx.pending.create(this.ctx.tool((json) => this.ctx.tools.execute(path, json), args))
}
// Fiber exits make settlement idempotent; yielding prevents inline continuation.
await(promise: PromiseObj): Effect.Effect<Value, unknown, never> {
await(promise: PromiseObj): Effect.Effect<unknown, unknown, never> {
const pending = this.ctx.pending
return Effect.suspend(() => {
pending.markObserved(promise)
@@ -455,9 +440,8 @@ class Frame<R> {
node: FunctionDeclaration | FunctionExpression | ArrowFunctionExpression,
name = node.type === "ArrowFunctionExpression" ? "" : (node.id?.name ?? ""),
): Fn {
const builtins = this.ctx.builtins
const fn = new Fn(
builtins.Function,
return new Fn(
this.ctx.builtins.Function,
name,
node.params,
node.body,
@@ -465,29 +449,20 @@ class Frame<R> {
node.async,
node.generator,
)
// Each generator function gets its own prototype, so `g() instanceof g` holds as in JS.
if (node.generator)
define(fn, "prototype", new Obj(node.async ? builtins.AsyncGenerator : builtins.Generator), hidden)
// The body of a named function expression sees its own name, read-only.
if (node.type === "FunctionExpression" && node.id) {
fn.capturedScopes.push(new Map([[node.id.name, { mutable: false, value: fn, initialized: true }]]))
}
return fn
}
// NamedEvaluation: an anonymous function definition takes the name of what it is assigned to.
private evaluateNamed(node: Expression, name: string): Effect.Effect<Value, unknown, R> {
private evaluateNamed(node: Expression, name: string): Effect.Effect<unknown, unknown, R> {
if (node.type === "ArrowFunctionExpression" || (node.type === "FunctionExpression" && !node.id)) {
return Effect.sync(() => this.createFunction(node, name))
}
return this.evaluateExpression(node)
}
// Repeated `function` declarations and `var` clashes are legal: the last declaration wins.
private hoistFunctions(statements: ReadonlyArray<Statement | ModuleDeclaration>): void {
for (const node of statements) {
if (node.type !== "FunctionDeclaration") continue
this.scopes.current().set(node.id.name, { mutable: true, value: this.createFunction(node), initialized: true })
this.scopes.declare(node.id.name, this.createFunction(node), true, node)
}
}
@@ -675,11 +650,11 @@ class Frame<R> {
if (declared?.lexical) self.predeclarePattern(declared.pattern, declared.mutable, left)
const right = yield* self.evaluateExpression(node.right)
const cursor = self.builtinCursor(right)
const iterator = cursor === undefined ? yield* self.customIterator(right, node, awaiting) : undefined
const iterator = yield* self.customIterator(right, node, awaiting)
const cursor = iterator === undefined ? yield* self.iterate(right, node) : undefined
if (iterator === undefined && cursor === undefined) {
throw invalidData(
`${awaiting ? "for await...of" : "for...of"} requires an iterable value, received ${describeValue(right)}.`,
`${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, URLSearchParams, or Headers, or custom iterator value.`,
node,
)
}
@@ -695,7 +670,7 @@ class Frame<R> {
}
const assignment = left.type === "VariableDeclaration" ? undefined : left
const evaluateBody = (value: Value) =>
const evaluateBody = (value: unknown) =>
Effect.gen(function* () {
if (declared?.lexical) {
self.scopes.push()
@@ -724,8 +699,10 @@ class Frame<R> {
const bodyExit = yield* Effect.exit(evaluateBody(step.value))
if (!Exit.isSuccess(bodyExit)) {
// Process interruption must remain prompt; user cleanup cannot extend a timeout.
if (Cause.hasInterruptsOnly(bodyExit.cause)) return yield* Effect.failCause(bodyExit.cause)
return yield* preserveConsumerError(close(), Effect.failCause(bodyExit.cause))
if (!Cause.hasInterruptsOnly(bodyExit.cause)) {
yield* Effect.exit(close())
}
return yield* Effect.failCause(bodyExit.cause)
}
const exit = loopExit(bodyExit.value, labels)
if (exit !== undefined) {
@@ -742,7 +719,7 @@ class Frame<R> {
)
}
private awaitValue(value: Value): Effect.Effect<Value, unknown, R> {
private awaitValue(value: unknown): Effect.Effect<unknown, unknown, R> {
return Effect.flatMap(resolvePromise(this.ctx, value), (promise) =>
Effect.ensuring(
this.await(promise),
@@ -753,10 +730,10 @@ class Frame<R> {
private awaitAsyncFromSyncValue(
iterator: CustomIterator,
value: Value,
value: unknown,
node: AstNode | undefined,
closeOnRejection: boolean,
): Effect.Effect<Value, unknown, R> {
): Effect.Effect<unknown, unknown, R> {
const self = this
return Effect.gen(function* () {
const settled = yield* Effect.exit(self.awaitValue(value))
@@ -768,50 +745,48 @@ class Frame<R> {
})
}
iterate(value: Value, node?: AstNode): Effect.Effect<Cursor<R> | undefined, unknown, R> {
const cursor = this.builtinCursor(value)
if (cursor !== undefined) return Effect.succeed(cursor)
return Effect.map(this.customIterator(value, node, false), (iterator) =>
iterator === undefined ? undefined : this.customCursor(iterator, node),
)
}
/** GetIteratorDirect: drive an iterator by its own `next`, without asking for `[Symbol.iterator]`. */
iterateDirect(value: Value, node?: AstNode): Cursor<R> {
if (value instanceof IteratorObj) return value.cursor as Cursor<R>
if (!(value instanceof Obj)) {
throw typeError(`An iterator must be an object, received ${describeValue(value)}.`, node)
}
return this.customCursor(
{
iterator: value,
next: this.requireIteratorMethod(get(value, "next"), "Iterator next", node),
asynchronous: false,
},
node,
)
}
private customCursor(iterator: CustomIterator, node: AstNode | undefined): Cursor<R> {
return {
next: this.nextIteratorResult(iterator, node, false),
close: Effect.suspend(() => this.closeIterator(iterator, node, false)),
}
}
private builtinCursor(value: Value): Cursor<R> | undefined {
// Natives build their cursors without knowing R, like `lift` in native.ts.
if (value instanceof IteratorObj) return value.cursor as Cursor<R>
iterate(value: unknown, node?: AstNode) {
const iterator =
typeof value === "string"
? value[Symbol.iterator]()
: value instanceof Obj
? value.iterator(this.ctx.builtins)
: undefined
return iterator === undefined ? undefined : hostCursor(iterator)
value instanceof Arr
? value.items[Symbol.iterator]()
: typeof value === "string"
? value[Symbol.iterator]()
: value instanceof MapObj
? value.map.entries()
: value instanceof SetObj
? value.set.values()
: value instanceof URLSearchParamsObj
? value.params.entries()
: value instanceof HeadersObj
? value.headers.entries()
: value instanceof Bytes
? value.bytes.values()
: undefined
if (iterator !== undefined) {
const proto = this.ctx.builtins.Array
return Effect.succeed({
next: Effect.sync(() => {
const step = iterator.next()
return {
done: Boolean(step.done),
value: Array.isArray(step.value) ? new Arr(proto, step.value) : step.value,
}
}),
close: Effect.void,
})
}
const self = this
return Effect.map(this.customIterator(value, node, false), (iterator) =>
iterator === undefined
? undefined
: {
next: self.nextIteratorResult(iterator, node, false),
close: Effect.suspend(() => self.closeIterator(iterator, node, false)),
},
)
}
private customIterator(value: Value, node: AstNode | undefined, allowAsync = true) {
private customIterator(value: unknown, node: AstNode | undefined, allowAsync = true) {
if (!(value instanceof Obj)) return Effect.undefined
const asyncMethod = allowAsync ? get(value, AsyncIteratorSymbol) : undefined
const method = asyncMethod ?? get(value, IteratorSymbol)
@@ -901,18 +876,18 @@ class Frame<R> {
})
}
private requireIteratorObject(value: Value, context: string, node?: AstNode): Obj {
private requireIteratorObject(value: unknown, context: string, node?: AstNode): Obj {
if (value instanceof Obj) return value
throw typeError(`${context} must be an object.`, node)
}
private requireIteratorMethod(value: Value, context: string, node?: AstNode): Value {
private requireIteratorMethod(value: unknown, context: string, node?: AstNode): unknown {
if (typeofValue(value) === "function") return value
throw typeError(`${context} must be a function.`, node)
}
// for...in over null/undefined iterates nothing, like JS.
private enumerableKeys(value: Value, node: AstNode): Array<string> {
private enumerableKeys(value: unknown, node: AstNode): Array<string> {
if (value instanceof ToolReference) return [...this.ctx.tools.keys(value.path)]
if (value === null || value === undefined) return []
return keys(enumerableSource(this.ctx, "for...in", value, node))
@@ -1076,7 +1051,7 @@ class Frame<R> {
private declarePattern(
pattern: Pattern,
value: Value,
value: unknown,
mutable: boolean,
node: AstNode,
initialize = false,
@@ -1136,7 +1111,7 @@ class Frame<R> {
})
}
private assignPattern(pattern: Pattern, value: Value, node: AstNode): Effect.Effect<void, unknown, R> {
private assignPattern(pattern: Pattern, value: unknown, node: AstNode): Effect.Effect<void, unknown, R> {
const self = this
return Effect.gen(function* () {
if (pattern.type === "Identifier") {
@@ -1188,7 +1163,7 @@ class Frame<R> {
})
}
private evaluateDefault(pattern: AssignmentPattern): Effect.Effect<Value, unknown, R> {
private evaluateDefault(pattern: AssignmentPattern): Effect.Effect<unknown, unknown, R> {
return pattern.left.type === "Identifier"
? this.evaluateNamed(pattern.right, pattern.left.name)
: this.evaluateExpression(pattern.right)
@@ -1196,8 +1171,8 @@ class Frame<R> {
private destructureArrayPattern(
pattern: ArrayPattern,
value: Value,
consume: (target: Pattern, value: Value, context: AstNode) => Effect.Effect<void, unknown, R>,
value: unknown,
consume: (target: Pattern, value: unknown, context: AstNode) => Effect.Effect<void, unknown, R>,
): Effect.Effect<void, unknown, R> {
const self = this
return Effect.gen(function* () {
@@ -1221,7 +1196,7 @@ class Frame<R> {
done = step.done
if (element === null) continue
if (element.type === "RestElement") {
const rest: Array<Value> = []
const rest: Array<unknown> = []
if (!step.done) rest.push(step.value)
while (!done) {
const next = yield* cursor.next
@@ -1232,7 +1207,7 @@ class Frame<R> {
return
}
const consumed = consume(element, step.done ? undefined : step.value, pattern)
yield* step.done ? consumed : preserveConsumerError(cursor.close, consumed)
yield* step.done ? consumed : preserveConsumerError(cursor, consumed)
}
if (!done) yield* cursor.close
})
@@ -1244,19 +1219,20 @@ class Frame<R> {
}
const keyNode = property.key
if (property.computed) {
return Effect.map(this.evaluateExpression(keyNode), (value) => this.toPropertyKey(value))
return Effect.map(this.evaluateExpression(keyNode), (value) => this.toPropertyKey(value, keyNode))
}
if (keyNode.type === "Identifier") return Effect.succeed(keyNode.name)
if (keyNode.type === "Literal") return Effect.succeed(String(keyNode.value))
throw unsupportedSyntax(keyNode.type, keyNode)
}
private evaluateExpression(node: Expression): Effect.Effect<Value, unknown, R> {
private evaluateExpression(node: Expression): Effect.Effect<unknown, unknown, R> {
switch (node.type) {
case "Literal": {
const regex = node.regex
if (regex) return Effect.sync(() => constructRegExp(this.ctx.builtins, [regex.pattern, regex.flags]))
return Effect.succeed(literal(node))
if (typeof node.value === "bigint") throw typeError("BigInt literals are not supported.", node)
return Effect.succeed(node.value)
}
case "Identifier":
return Effect.sync(() => this.scopes.get(node.name, node))
@@ -1271,7 +1247,7 @@ class Frame<R> {
case "SequenceExpression": {
const self = this
return Effect.gen(function* () {
let result: Value
let result: unknown
for (const expression of node.expressions) {
result = yield* self.evaluateExpression(expression)
}
@@ -1312,7 +1288,7 @@ class Frame<R> {
}
}
private evaluateNewExpression(node: NewExpression): Effect.Effect<Value, unknown, R> {
private evaluateNewExpression(node: NewExpression): Effect.Effect<unknown, unknown, R> {
const self = this
return Effect.gen(function* () {
const callee = yield* self.evaluateExpression(node.callee)
@@ -1322,7 +1298,7 @@ class Frame<R> {
// `new` itself is supported, so a non-constructible callee is a TypeError like JS rather than
// unsupported syntax. Built-ins like Number are real constructors in JS, so do not claim
// otherwise; say `new` is unsupported for them and point at the plain call.
const name = calleeDescription(node.callee) ?? "The called value"
const name = calleeDescription(node.callee)
const message =
callee instanceof Fn
? `${name} cannot be constructed: user-defined constructors and classes are not supported. Call it as a function that returns a plain object instead.`
@@ -1336,7 +1312,7 @@ class Frame<R> {
})
}
private evaluateBinaryExpression(node: BinaryExpression): Effect.Effect<Value, unknown, R> {
private evaluateBinaryExpression(node: BinaryExpression): Effect.Effect<unknown, unknown, R> {
const operator = node.operator
const left = node.left
if (left.type === "PrivateIdentifier") throw unsupportedSyntax(left.type, left)
@@ -1349,7 +1325,7 @@ class Frame<R> {
})
}
private applyBinaryOperator(operator: string, lhs: Value, rhs: Value, node: AstNode): Value {
private applyBinaryOperator(operator: string, lhs: unknown, rhs: unknown, node: AstNode): unknown {
if (operator === "===") return lhs === rhs
if (operator === "!==") return lhs !== rhs
if (operator === "in" && rhs instanceof Obj && !containsOpaqueReference(lhs)) {
@@ -1358,9 +1334,14 @@ class Frame<R> {
if (containsOpaqueReference(lhs) || containsOpaqueReference(rhs)) {
throw invalidData("Binary operators require data values.", node)
}
// Addition and loose equality use the default hint; every other operator asks for a number.
const hint = operator === "+" || operator === "==" || operator === "!=" ? "default" : "number"
const coerceOperand = (operand: Value) => (operand instanceof Obj ? operand.toPrimitive(hint) : operand)
// Null-prototype data needs explicit primitive coercion; identity and `in` retain raw objects.
// Dates use their default string hint for addition and loose equality, and epoch time elsewhere.
const coerceOperand = (operand: unknown): unknown => {
if (operand instanceof DateObj) {
return operator === "+" || operator === "==" || operator === "!=" ? coerceToString(operand) : operand.time
}
return operand !== null && typeof operand === "object" ? coerceToString(operand) : operand
}
const bothObjects = lhs !== null && typeof lhs === "object" && rhs !== null && typeof rhs === "object"
const l = coerceOperand(lhs)
const r = coerceOperand(rhs)
@@ -1414,7 +1395,7 @@ class Frame<R> {
}
}
private evaluateLogicalExpression(node: LogicalExpression): Effect.Effect<Value, unknown, R> {
private evaluateLogicalExpression(node: LogicalExpression): Effect.Effect<unknown, unknown, R> {
const operator = node.operator
return Effect.flatMap(this.evaluateExpression(node.left), (left) => {
if (operator === "&&") return left ? this.evaluateExpression(node.right) : Effect.succeed(left)
@@ -1425,7 +1406,7 @@ class Frame<R> {
})
}
private evaluateUnaryExpression(node: UnaryExpression): Effect.Effect<Value, unknown, R> {
private evaluateUnaryExpression(node: UnaryExpression): Effect.Effect<unknown, unknown, R> {
const operator = node.operator
const argument = node.argument
if (operator === "delete") return this.evaluateDeleteExpression(argument)
@@ -1440,8 +1421,13 @@ class Frame<R> {
if (containsOpaqueReference(value)) {
throw invalidData("Unary operators require data values.", node)
}
const operand = value instanceof Obj ? value.toPrimitive("number") : value
let result: Value
const operand =
value instanceof DateObj
? value.time
: value !== null && typeof value === "object"
? coerceToString(value)
: value
let result: unknown
switch (operator) {
case "+":
result = +(operand as number)
@@ -1459,7 +1445,7 @@ class Frame<R> {
})
}
private evaluateAssignmentExpression(node: AssignmentExpression): Effect.Effect<Value, unknown, R> {
private evaluateAssignmentExpression(node: AssignmentExpression): Effect.Effect<unknown, unknown, R> {
const left = node.left
const operator = node.operator
const self = this
@@ -1499,9 +1485,9 @@ class Frame<R> {
node: AssignmentExpression,
left: Pattern,
operator: string,
): Effect.Effect<Value, unknown, R> {
): Effect.Effect<unknown, unknown, R> {
const self = this
const shouldAssign = (current: Value): boolean =>
const shouldAssign = (current: unknown): boolean =>
operator === "??=" ? current === null || current === undefined : operator === "||=" ? !current : Boolean(current)
if (left.type === "Identifier") {
const name = left.name
@@ -1526,7 +1512,7 @@ class Frame<R> {
throw typeError("Assignment target must be an Identifier or MemberExpression.", left)
}
private evaluateUpdateExpression(node: UpdateExpression): Effect.Effect<Value, unknown, R> {
private evaluateUpdateExpression(node: UpdateExpression): Effect.Effect<unknown, unknown, R> {
const operator = node.operator
const argument = node.argument
const prefix = node.prefix
@@ -1539,7 +1525,7 @@ class Frame<R> {
// CodeMode numeric coercion, not host Number(): null-prototype data objects would make
// the host throw during ToPrimitive, and opaque runtime references must reject clearly.
const operand = (current: Value): number => {
const operand = (current: unknown): number => {
if (containsOpaqueReference(current)) {
throw invalidData(`'${operator}' requires a data value.`, argument)
}
@@ -1568,7 +1554,7 @@ class Frame<R> {
}
// EvaluateCall: a member callee supplies its base object as `this`; anything else calls with undefined.
private evaluateCallExpression(node: CallExpression): Effect.Effect<Value, unknown, R> {
private evaluateCallExpression(node: CallExpression): Effect.Effect<unknown, unknown, R> {
const callee = node.callee
const self = this
@@ -1586,7 +1572,7 @@ class Frame<R> {
})
}
private readMethod(node: MemberExpression): Effect.Effect<{ callable: Value; thisValue: Value }, unknown, R> {
private readMethod(node: MemberExpression): Effect.Effect<{ callable: unknown; thisValue: unknown }, unknown, R> {
return Effect.map(this.getMemberReference(node), (reference) => {
if (reference === OptionalShortCircuit) return { callable: OptionalShortCircuit, thisValue: undefined }
if (reference instanceof ToolReference) return { callable: reference, thisValue: undefined }
@@ -1597,12 +1583,12 @@ class Frame<R> {
// The single dispatch for every invocation: call expressions and callbacks share it.
call(
callable: Value,
thisValue: Value,
args: Array<Value>,
callable: unknown,
thisValue: unknown,
args: Array<unknown>,
node?: AstNode,
callee?: Expression,
): Effect.Effect<Value, unknown, R> {
): Effect.Effect<unknown, unknown, R> {
const self = this
return Effect.gen(function* () {
if (callable instanceof ToolReference) {
@@ -1615,12 +1601,12 @@ class Frame<R> {
if (callable instanceof Native) {
return yield* self.native(() => (callable as Native<R>).call(thisValue, args), node)
}
throw typeError(`${calleeDescription(callee) ?? "The called value"} is not a function.`, callee ?? node)
throw typeError(`${calleeDescription(callee)} is not a function.`, callee ?? node)
})
}
// Built-ins throw without a location, synchronously or inside their Effect; the call site supplies it.
private native(body: () => Effect.Effect<Value, unknown, R>, node?: AstNode): Effect.Effect<Value, unknown, R> {
private native(body: () => Effect.Effect<unknown, unknown, R>, node?: AstNode): Effect.Effect<unknown, unknown, R> {
return Effect.provideService(
Effect.catchDefect(Effect.suspend(body), (defect) => Effect.die(locate(defect, node))),
CallSite,
@@ -1630,10 +1616,10 @@ class Frame<R> {
private evaluateCallArguments(
argNodes: ReadonlyArray<Expression | SpreadElement>,
): Effect.Effect<Array<Value>, unknown, R> {
): Effect.Effect<Array<unknown>, unknown, R> {
const self = this
return Effect.gen(function* () {
const args: Array<Value> = []
const args: Array<unknown> = []
for (const argNode of argNodes) {
if (argNode.type === "SpreadElement") {
const spread = yield* self.evaluateExpression(argNode.argument)
@@ -1653,20 +1639,20 @@ class Frame<R> {
}
// A callback invoked by a built-in runs below the call that invoked the built-in, so the deeper of the two counts.
invokeFunction(fn: Fn, args: Array<Value>, node?: AstNode): Effect.Effect<Value, unknown, R> {
invokeFunction(fn: Fn, args: Array<unknown>, node?: AstNode): Effect.Effect<unknown, unknown, R> {
const self = this
return Effect.flatMap(CallSite, (site) => {
const depth = Math.max(self.depth, site.depth) + 1
if (depth > MAX_CALL_DEPTH) throw rangeError("Maximum call stack size exceeded", node)
const invocation = new Frame(this.ctx, new ScopeStack([...fn.capturedScopes, new Map()]), depth)
// Seed all parameters first so defaults cannot fall through to same-named outer bindings.
const paramScope = invocation.scopes.current()
for (const parameter of fn.parameters) {
for (const name of collectPatternNames(parameter)) {
paramScope.set(name, { mutable: true, value: undefined, initialized: false })
const run = Effect.gen(function* () {
// Seed all parameters first so defaults cannot fall through to same-named outer bindings.
const paramScope = invocation.scopes.current()
for (const parameter of fn.parameters) {
for (const name of collectPatternNames(parameter)) {
paramScope.set(name, { mutable: true, value: undefined, initialized: false })
}
}
}
const bind = Effect.gen(function* () {
for (const [index, parameter] of fn.parameters.entries()) {
if (parameter.type === "RestElement") {
yield* invocation.declarePattern(
@@ -1680,8 +1666,7 @@ class Frame<R> {
}
yield* invocation.declarePattern(parameter, args[index], true, parameter, true)
}
})
const body = Effect.gen(function* () {
if (fn.body.type === "BlockStatement") {
invocation.scopes.push()
invocation.hoistVars(fn.body.body, paramScope)
@@ -1691,9 +1676,7 @@ class Frame<R> {
return yield* invocation.evaluateExpression(fn.body)
})
// Generators bind parameters at the call and defer only the body to the first `next()`, as in JS.
if (fn.generator) return Effect.map(bind, () => this.createGenerator(invocation, body, fn))
const run = Effect.andThen(bind, body)
if (fn.generator) return Effect.succeed(this.createGenerator(invocation, run, fn.async))
if (!fn.async) return run
return this.ctx.pending.createWithSelf((self) =>
Effect.flatMap(run, (value) => resolvePromiseValue(invocation.ctx, value, self)),
@@ -1701,15 +1684,18 @@ class Frame<R> {
})
}
private createGenerator(invocation: Frame<R>, run: Effect.Effect<Value, unknown, R>, fn: Fn): GeneratorObj {
const asynchronous = fn.async
private createGenerator(
invocation: Frame<R>,
run: Effect.Effect<unknown, unknown, R>,
asynchronous: boolean,
): GeneratorObj {
const state: GeneratorState = { started: false, completed: false, draining: false, pending: [], pendingIndex: 0 }
invocation.generatorState = state
invocation.generatorAsync = asynchronous
const builtins = this.ctx.builtins
const result = (value: Value, done: boolean) => record(builtins.Object, { value, done })
const request = (kind: GeneratorRequestKind, value: Value) => {
const request = { kind, value, response: Deferred.makeUnsafe<Value, unknown>() }
const result = (value: unknown, done: boolean) => record(builtins.Object, { value, done })
const request = (kind: GeneratorRequestKind, value: unknown) => {
const request = { kind, value, response: Deferred.makeUnsafe<unknown, unknown>() }
if (!asynchronous && state.active) return Effect.die(typeError("Generator is already running."))
if (asynchronous && (state.completed || (!state.started && kind !== "next"))) {
state.started = true
@@ -1770,13 +1756,17 @@ class Frame<R> {
}
return Deferred.await(request.response)
}
const proto = get(fn, "prototype")
return new GeneratorObj(proto instanceof Obj ? proto : builtins.Generator, asynchronous, request)
const generator = new GeneratorObj(
asynchronous ? builtins.AsyncGenerator : builtins.Generator,
asynchronous,
request,
)
return generator
}
private completeGeneratorRequests(state: GeneratorState, asynchronous: boolean): Effect.Effect<void, never, R> {
const self = this
const result = (value: Value, done: boolean) => record(self.ctx.builtins.Object, { value, done })
const result = (value: unknown, done: boolean) => record(self.ctx.builtins.Object, { value, done })
return Effect.gen(function* () {
while (true) {
const pending = self.dequeueGeneratorRequest(state)
@@ -1822,7 +1812,7 @@ class Frame<R> {
return request
}
private evaluateYieldExpression(node: YieldExpression): Effect.Effect<Value, unknown, R> {
private evaluateYieldExpression(node: YieldExpression): Effect.Effect<unknown, unknown, R> {
const argument = node.argument
const self = this
return Effect.gen(function* () {
@@ -1837,7 +1827,7 @@ class Frame<R> {
})
}
private suspendGenerator(value: Value, node: AstNode): Effect.Effect<Value, unknown, R> {
private suspendGenerator(value: unknown, node: AstNode): Effect.Effect<unknown, unknown, R> {
const state = this.generatorState
if (!state?.active) throw typeError("Generator has no active request.", node)
Deferred.doneUnsafe(state.active.response, Exit.succeed(record(this.ctx.builtins.Object, { value, done: false })))
@@ -1852,11 +1842,20 @@ class Frame<R> {
})
}
private delegateYield(value: Value, node: AstNode): Effect.Effect<Value, unknown, R> {
private delegateYield(value: unknown, node: AstNode): Effect.Effect<unknown, unknown, R> {
const self = this
return Effect.gen(function* () {
const cursor = self.builtinCursor(value)
if (cursor !== undefined) {
if (
value instanceof Arr ||
typeof value === "string" ||
value instanceof MapObj ||
value instanceof SetObj ||
value instanceof URLSearchParamsObj ||
value instanceof HeadersObj ||
value instanceof Bytes
) {
const cursor = yield* self.iterate(value, node)
if (!cursor) throw typeError("Built-in iterator is unavailable.", node)
while (true) {
const step = yield* cursor.next
if (step.done) return undefined
@@ -1880,7 +1879,7 @@ class Frame<R> {
const iterator = yield* self.customIterator(value, node, self.generatorAsync)
if (!iterator) throw typeError("yield* requires a compatible iterable value.", node)
let kind: GeneratorRequestKind = "next"
let input: Value = undefined
let input: unknown = undefined
while (true) {
const method = kind === "next" ? iterator.next : get(iterator.iterator, kind)
if (method === undefined || method === null) {
@@ -1900,7 +1899,7 @@ class Frame<R> {
node,
)
const done = Boolean(get(result, "done"))
const resultValue: Value =
const resultValue: unknown =
self.generatorAsync && !iterator.asynchronous
? yield* self.awaitAsyncFromSyncValue(iterator, get(result, "value"), node, kind !== "return" && !done)
: get(result, "value")
@@ -1909,7 +1908,7 @@ class Frame<R> {
return resultValue
}
const resumed: Exit.Exit<Value, unknown> = yield* Effect.exit(self.suspendGenerator(resultValue, node))
const resumed: Exit.Exit<unknown, unknown> = yield* Effect.exit(self.suspendGenerator(resultValue, node))
if (Exit.isSuccess(resumed)) {
kind = "next"
input = resumed.value
@@ -1946,11 +1945,11 @@ class Frame<R> {
let key: PropertyKey
if (property.computed) {
key = self.toPropertyKey(yield* self.evaluateExpression(keyNode))
key = self.toPropertyKey(yield* self.evaluateExpression(keyNode), keyNode)
} else if (keyNode.type === "Identifier") {
key = keyNode.name
} else if (keyNode.type === "Literal") {
key = self.toPropertyKey(literal(keyNode))
key = self.toPropertyKey(keyNode.value, keyNode)
} else {
throw typeError("Unsupported object property key shape.", keyNode)
}
@@ -1969,7 +1968,7 @@ class Frame<R> {
}
private evaluateArrayExpression(node: ArrayExpression): Effect.Effect<Arr, unknown, R> {
const values: Array<Value> = []
const values: Array<unknown> = []
const self = this
return Effect.gen(function* () {
@@ -2024,13 +2023,13 @@ class Frame<R> {
})
}
private evaluateConditionalExpression(node: ConditionalExpression): Effect.Effect<Value, unknown, R> {
private evaluateConditionalExpression(node: ConditionalExpression): Effect.Effect<unknown, unknown, R> {
return Effect.flatMap(this.evaluateExpression(node.test), (test) =>
this.evaluateExpression(test ? node.consequent : node.alternate),
)
}
private applyCompoundAssignment(operator: string, current: Value, incoming: Value, node: AstNode): Value {
private applyCompoundAssignment(operator: string, current: unknown, incoming: unknown, node: AstNode): unknown {
if (!compoundOperators.has(operator)) {
throw typeError(`Unsupported assignment operator '${operator}'.`, node)
}
@@ -2039,7 +2038,7 @@ class Frame<R> {
private getMemberReference(
node: MemberExpression,
): Effect.Effect<MemberReference | ToolReference | { value: Value } | typeof OptionalShortCircuit, unknown, R> {
): Effect.Effect<MemberReference | ToolReference | { value: unknown } | typeof OptionalShortCircuit, unknown, R> {
const objectNode = node.object
const propertyNode = node.property
if (objectNode.type === "Super") throw unsupportedSyntax(objectNode.type, objectNode)
@@ -2051,10 +2050,10 @@ class Frame<R> {
if ((objectValue === null || objectValue === undefined) && node.optional) return OptionalShortCircuit
const key = node.computed
? self.toPropertyKey(yield* self.evaluateExpression(propertyNode))
? self.toPropertyKey(yield* self.evaluateExpression(propertyNode), propertyNode)
: propertyNode.type === "Identifier"
? propertyNode.name
: self.toPropertyKey(yield* self.evaluateExpression(propertyNode))
: self.toPropertyKey(yield* self.evaluateExpression(propertyNode), propertyNode)
if (objectValue instanceof ToolReference) {
if (typeof key !== "string") {
@@ -2065,14 +2064,16 @@ class Frame<R> {
if (objectValue instanceof Obj) return { target: objectValue, key, receiver: objectValue }
// Strings own length and indexes; every other primitive property reads through the wrapper prototype.
// Primitives read through their wrapper prototype without being boxed; strings own length and indexes.
const builtins = self.ctx.builtins
if (typeof objectValue === "string") {
if (key === "length") return { value: objectValue.length }
const index = typeof key === "symbol" ? undefined : parseArrayIndex(key)
if (index !== undefined) return { value: objectValue[index] }
return { target: builtins.String, key, receiver: objectValue }
}
const proto = primitivePrototype(self.ctx.builtins, objectValue)
if (proto !== undefined) return { target: proto, key, receiver: objectValue }
if (typeof objectValue === "number") return { target: builtins.Number, key, receiver: objectValue }
if (typeof objectValue === "boolean") return { target: builtins.Boolean, key, receiver: objectValue }
if (objectValue === null || objectValue === undefined) {
throw typeError(`Cannot read properties of ${objectValue} (reading '${String(key)}').`, objectNode)
@@ -2081,7 +2082,7 @@ class Frame<R> {
})
}
private readReference(reference: MemberReference, node: MemberExpression): Value {
private readReference(reference: MemberReference, node: MemberExpression): unknown {
// Reject unknown promise properties so a missing await cannot hide.
if (reference.target instanceof PromiseObj && !has(reference.target, reference.key)) {
throw invalidData(
@@ -2093,7 +2094,7 @@ class Frame<R> {
}
// Accessors throw without a location; the member or pattern that read them supplies it.
private readProperty(target: Obj, key: PropertyKey, node: AstNode, receiver: Value = target): Value {
private readProperty(target: Obj, key: PropertyKey, node: AstNode, receiver: unknown = target): unknown {
try {
return get(target, key, receiver)
} catch (error) {
@@ -2101,7 +2102,7 @@ class Frame<R> {
}
}
private readMember(node: MemberExpression): Effect.Effect<Value, unknown, R> {
private readMember(node: MemberExpression): Effect.Effect<unknown, unknown, R> {
return Effect.map(this.getMemberReference(node), (reference) => {
if (reference === OptionalShortCircuit) return OptionalShortCircuit
if (reference instanceof ToolReference) return reference
@@ -2110,15 +2111,15 @@ class Frame<R> {
})
}
private writeMember(node: MemberExpression, value: Value): Effect.Effect<Value, unknown, R> {
private writeMember(node: MemberExpression, value: unknown): Effect.Effect<unknown, unknown, R> {
return this.modifyMember(node, () => Effect.succeed({ write: true, next: value, result: value }))
}
private evaluateDeleteExpression(argument: Expression): Effect.Effect<boolean, unknown, R> {
const target = argument.type === "ChainExpression" ? argument.expression : argument
if (target.type === "Identifier") throw typeError("Only data fields may be deleted.", argument)
// `delete <non-reference>` evaluates the operand and is true, as in JS.
if (target.type !== "MemberExpression") return Effect.map(this.evaluateExpression(target), () => true)
if (target.type !== "MemberExpression") {
throw typeError("Only data fields may be deleted.", argument)
}
return Effect.map(this.getMemberReference(target), (reference) => {
if (reference === OptionalShortCircuit) return true
if (reference instanceof ToolReference || "value" in reference || reference.receiver !== reference.target) {
@@ -2132,8 +2133,8 @@ class Frame<R> {
// Resolve side-effecting object and key expressions exactly once.
private modifyMember(
node: MemberExpression,
compute: (current: Value) => Effect.Effect<{ write: boolean; next: Value; result: Value }, unknown, R>,
): Effect.Effect<Value, unknown, R> {
compute: (current: unknown) => Effect.Effect<{ write: boolean; next: unknown; result: unknown }, unknown, R>,
): Effect.Effect<unknown, unknown, R> {
const self = this
return Effect.gen(function* () {
const reference = yield* self.getMemberReference(node)
@@ -2153,7 +2154,7 @@ class Frame<R> {
})
}
private assignToReference(target: Obj, key: PropertyKey, next: Value, node: AstNode): void {
private assignToReference(target: Obj, key: PropertyKey, next: unknown, node: AstNode): void {
const written = (() => {
try {
rejectCircularInsertion(
@@ -2171,9 +2172,12 @@ class Frame<R> {
throw typeError(`Cannot assign to read only property '${String(key)}'.`, node)
}
// ToPropertyKey: anything else becomes its string form, so `counts[row.category]` works when the field is null.
private toPropertyKey(value: Value): PropertyKey {
if (typeof value === "string" || typeof value === "number" || typeof value === "symbol") return value
return coerceToString(value)
private toPropertyKey(value: unknown, node: AstNode): PropertyKey {
if (typeof value === "string" || typeof value === "number") {
return value
}
if (value === AsyncIteratorSymbol || value === IteratorSymbol) return value
throw typeError("Property key must be a string or number, or Symbol.asyncIterator/Symbol.iterator.", node)
}
}
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define, hidden, Native, Arr, ErrorObj, Obj, type Value } from "./objects.js"
import { define, hidden, Native, Arr, ErrorObj, Obj } from "./objects.js"
export const errorTypes = [
"Error",
@@ -35,7 +35,6 @@ const builtins = [
"TextDecoder",
"Promise",
"Iterator",
"IteratorHelper",
"AsyncIterator",
"Generator",
"AsyncGenerator",
@@ -53,14 +52,6 @@ export const createErrorValue = (prototype: Obj, message: string | undefined): E
return value
}
/** The prototype a primitive reads its methods from without being boxed; none for null, undefined, and symbols. */
export const primitivePrototype = (builtins: Builtins, value: Value): Obj | undefined => {
if (typeof value === "string") return builtins.String
if (typeof value === "number") return builtins.Number
if (typeof value === "boolean") return builtins.Boolean
return undefined
}
export const createBuiltins = (): Builtins => {
const object = new Obj(null)
// Function.prototype is itself callable and returns undefined.
@@ -96,7 +87,6 @@ export const createBuiltins = (): Builtins => {
TextDecoder: plain(),
Promise: plain(),
Iterator: iterator,
IteratorHelper: new Obj(iterator),
AsyncIterator: asyncIterator,
Generator: new Obj(iterator),
AsyncGenerator: new Obj(asyncIterator),
+10 -8
View File
@@ -2,7 +2,7 @@ import type { Node } from "acorn"
import { Context } from "effect"
import type { ErrorType } from "./intrinsics.js"
import type { DiagnosticKind } from "../codemode.js"
import type { ErrorObj, Value } from "./objects.js"
import type { ErrorObj } from "./objects.js"
/** Any parsed node; the interpreter narrows on `type` and reads `loc` for diagnostics. */
export type AstNode = Node
@@ -14,13 +14,13 @@ export const CallSite = Context.Reference<{ readonly node?: AstNode; readonly de
export type Binding = {
mutable: boolean
value: Value
value: unknown
initialized?: boolean
}
export type StatementResult =
| { kind: "none" }
| { kind: "return"; value: Value }
| { kind: "return"; value: unknown }
| { kind: "break"; label?: string }
| { kind: "continue"; label?: string }
@@ -31,11 +31,11 @@ export const IteratorSymbol: unique symbol = Symbol("codemode.iterator")
export const IteratorSymbols = [AsyncIteratorSymbol, IteratorSymbol] as const
export class Throw {
constructor(readonly value: Value) {}
constructor(readonly value: unknown) {}
}
export class GeneratorReturn {
constructor(readonly value: Value) {}
constructor(readonly value: unknown) {}
}
export const OptionalShortCircuit: unique symbol = Symbol("codemode.optional-short-circuit")
@@ -83,10 +83,12 @@ export const unsupportedSyntax = (kind: string, node: AstNode): PendingThrow =>
[supportedSyntaxMessage],
)
// Acorn lines are 1-based and its columns are 0-based. Diagnostics use 1-based columns of the submitted source.
export const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null
export const sourceLocation = (node: AstNode): { readonly line: number; readonly column: number } => ({
line: node.loc?.start.line ?? 1,
column: (node.loc?.start.column ?? 0) + 1,
line: Math.max(1, (node.loc?.start.line ?? 2) - 1),
column: Math.max(1, (node.loc?.start.column ?? 4) - 3),
})
export const formatLocation = (node?: AstNode): string => {
+7 -7
View File
@@ -1,18 +1,18 @@
import { Effect } from "effect"
import type { Builtins } from "./intrinsics.js"
import { typeError } from "./model.js"
import { type Callable, define, frozen, hidden, Native, type NativeOptions, Obj, type Value } from "./objects.js"
import { type Callable, define, frozen, hidden, Native, type NativeOptions, Obj } from "./objects.js"
import { describeValue } from "./references.js"
/** A native function body: a value, a thrown `PendingThrow`, or an Effect of a value. */
export type Impl = (thisValue: Value, args: Array<Value>) => Value | Effect.Effect<Value, unknown, unknown>
/** A native function body: a plain value, a thrown `PendingThrow`, or an Effect. */
export type Impl = (thisValue: unknown, args: Array<unknown>) => unknown
// The dispatch in `Frame.call` suspends every native call, so a synchronous throw here is a defect.
const lift =
<R>(impl: Impl) =>
(thisValue: Value, args: Array<Value>): Effect.Effect<Value, unknown, R> => {
(thisValue: unknown, args: Array<unknown>): Effect.Effect<unknown, unknown, R> => {
const result = impl(thisValue, args)
return Effect.isEffect(result) ? (result as Effect.Effect<Value, unknown, R>) : Effect.succeed(result)
return Effect.isEffect(result) ? (result as Effect.Effect<unknown, unknown, R>) : Effect.succeed(result)
}
export const native = <R>(builtins: Builtins, options: NativeOptions<R>): Native<R> =>
@@ -27,7 +27,7 @@ export const methods = (builtins: Builtins, target: Obj, table: ReadonlyArray<Me
for (const [name, length, impl] of table) define(target, name, fn(builtins, name, length, impl), hidden)
}
export const constants = (target: Obj, table: Record<string, Value>): void => {
export const constants = (target: Obj, table: Record<string, unknown>): void => {
for (const [name, value] of Object.entries(table)) define(target, name, value, frozen)
}
@@ -54,7 +54,7 @@ export const prototypeFrom = (newTarget: Callable, fallback: Obj): Obj => {
/** Narrows a method receiver to the built-in it belongs to, or throws the TypeError JS would. */
export const receiver = <T extends Obj>(
cls: abstract new (...args: never) => T,
thisValue: Value,
thisValue: unknown,
method: string,
): T => {
if (thisValue instanceof cls) return thisValue
+46 -278
View File
@@ -1,7 +1,5 @@
import type { BlockStatement, Expression, Pattern } from "acorn"
import { Effect, type Fiber } from "effect"
import { ToolReference } from "../tool-runtime.js"
import type { Builtins } from "./intrinsics.js"
import type { Effect, Fiber } from "effect"
import { checkArrayLength } from "./limits.js"
import {
AsyncIteratorSymbol,
@@ -18,12 +16,12 @@ export type Attributes = {
readonly configurable: boolean
}
export type Getter = (receiver: Value) => Value
export type Setter = (receiver: Value, value: Value) => void
export type Getter = (receiver: unknown) => unknown
export type Setter = (receiver: unknown, value: unknown) => void
/** One own property: a data slot or a native accessor pair. */
export type Slot =
| { value: Value; writable: boolean; enumerable: boolean; configurable: boolean }
| { value: unknown; writable: boolean; enumerable: boolean; configurable: boolean }
| { get: Getter | undefined; set: Setter | undefined; enumerable: boolean; configurable: boolean }
/** Ordinary assignment: writable, enumerable, configurable. */
@@ -35,122 +33,33 @@ export const readonly: Attributes = { writable: false, enumerable: false, config
/** Constants such as `Math.PI` and a constructor's `prototype`. */
export const frozen: Attributes = { writable: false, enumerable: false, configurable: false }
/**
* An object owned by the program: own properties plus a prototype link. Subclasses answer, in one place, how a
* built-in kind of object prints, coerces, iterates, and crosses to the host.
*/
/** An object owned by the program: own properties plus a prototype link. */
export class Obj {
readonly props = new Map<string | symbol, Slot>()
constructor(public proto: Obj | null) {}
/** The class name `Object.prototype.toString` reports: `[object Map]`. */
readonly tag: string = "Object"
/** How diagnostics refer to a value of this kind. */
get describe(): string {
if (this.tag === "Object") return "a data object"
return `${/^[AEIO]/.test(this.tag) ? "an" : "a"} ${this.tag}`
}
/** ToString without consulting program-defined methods. */
toString(): string {
return `[object ${this.tag}]`
}
/** ToPrimitive without consulting program-defined methods: only a Date answers a number hint differently. */
toPrimitive(hint: "default" | "number" | "string"): string | number {
return this.toString()
}
/** ToNumber without consulting program-defined methods. */
toNumber(): number {
return Number(this.toPrimitive("number"))
}
/** How `console.log` shows the value; `item` formats a child with cycle and depth tracking. */
inspect(item: (value: Value) => string): string {
return `{${entries(this)
.map(([key, value]) => `${JSON.stringify(key)}:${item(value)}`)
.join(",")}}`
}
/** A copy the host can hold; `item` converts a child. A `__proto__` key never reaches host code. */
toHost(item: (value: Value) => unknown): unknown {
return Object.fromEntries(
entries(this)
.filter(([key]) => key !== "__proto__")
.map(([key, value]) => [key, item(value)]),
)
}
/** The built-in iteration `for...of` and spread use, when this kind of object has one. */
iterator(builtins: Builtins): Iterator<Value, undefined> | undefined {
return undefined
}
}
export class Arr extends Obj {
override readonly tag = "Array"
constructor(
proto: Obj,
readonly items: Array<Value> = [],
readonly items: Array<unknown> = [],
) {
super(proto)
}
override get describe() {
return "an array"
}
override toString() {
return this.items.map((item) => (item === null || item === undefined ? "" : coerceToString(item))).join(",")
}
override inspect(item: (value: Value) => string) {
return `[${this.items.map(item).join(",")}]`
}
override toHost(item: (value: Value) => unknown) {
return this.items.map(item)
}
override iterator() {
return this.items.values()
}
}
/** An object with the [[ErrorData]] slot: what `Error.prototype.toString` and the host boundary recognize as an error. */
export class ErrorObj extends Obj {
override readonly tag = "Error"
/** The interpreter failure this error materialized from, so rethrowing it keeps the diagnostic kind and location. */
host?: PendingThrow
/** Error.prototype.toString: "name: message", or just one when the other is empty. */
override toString() {
const name = get(this, "name")
const message = get(this, "message")
const shownName = typeof name === "string" ? name : "Error"
const shownMessage = typeof message === "string" ? message : ""
if (shownMessage === "") return shownName
if (shownName === "") return shownMessage
return `${shownName}: ${shownMessage}`
}
override inspect() {
return this.toString()
}
}
/** Interpreter machinery a program can hold but never inspect, serialize, or hand to the host. */
export abstract class Opaque extends Obj {
override inspect() {
return "[opaque reference]"
}
}
export abstract class Callable extends Opaque {
override readonly tag = "Function"
export abstract class Callable extends Obj {
constructor(proto: Obj, name: string, length: number) {
super(proto)
define(this, "length", length, readonly)
define(this, "name", name, readonly)
}
override get describe() {
return "a function"
}
}
export class Fn extends Callable {
@@ -159,7 +68,7 @@ export class Fn extends Callable {
name: string,
readonly parameters: ReadonlyArray<Pattern>,
readonly body: BlockStatement | Expression,
readonly capturedScopes: Array<Map<string, Binding>>,
readonly capturedScopes: ReadonlyArray<Map<string, Binding>>,
readonly async: boolean,
readonly generator: boolean,
) {
@@ -168,8 +77,8 @@ export class Fn extends Callable {
}
}
export type NativeCall<R> = (thisValue: Value, args: Array<Value>) => Effect.Effect<Value, unknown, R>
export type NativeConstruct<R> = (args: Array<Value>, newTarget: Callable) => Effect.Effect<Value, unknown, R>
export type NativeCall<R> = (thisValue: unknown, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
export type NativeConstruct<R> = (args: Array<unknown>, newTarget: Callable) => Effect.Effect<unknown, unknown, R>
export type NativeOptions<R> = {
readonly name: string
@@ -194,182 +103,69 @@ export class Native<R = never> extends Callable {
}
}
export class PromiseObj extends Opaque {
override readonly tag = "Promise"
export class PromiseObj extends Obj {
constructor(
proto: Obj,
readonly fiber: Fiber.Fiber<Value, unknown>,
readonly fiber: Fiber.Fiber<unknown, unknown>,
) {
super(proto)
}
override get describe() {
return "an un-awaited Promise"
}
override inspect() {
return "[Promise (await it to get its value)]"
}
}
export class GeneratorObj extends Opaque {
override readonly tag = "Generator"
export class GeneratorObj extends Obj {
constructor(
proto: Obj,
readonly asynchronous: boolean,
readonly request: (kind: GeneratorRequestKind, value: Value) => Effect.Effect<Value, unknown, unknown>,
readonly request: (kind: GeneratorRequestKind, value: unknown) => Effect.Effect<unknown, unknown, unknown>,
) {
super(proto)
}
override get describe() {
return "a generator"
}
}
/** One pull from an iterator, as `for...of` sees it. */
export type Step = { readonly done: boolean; readonly value: Value }
/** How the interpreter drives any iterator: pull the next step, or close it early. */
export type Cursor<R = unknown> = {
readonly next: Effect.Effect<Step, unknown, R>
readonly close: Effect.Effect<void, unknown, R>
}
/** A cursor over a host iterator; there is nothing to close. */
export const hostCursor = (iterator: Iterator<Value, undefined>): Cursor<never> => ({
next: Effect.sync(() => {
const step = iterator.next()
return { done: Boolean(step.done), value: step.value }
}),
close: Effect.void,
})
/** A built-in iterator: a live cursor over a host collection or an iterator helper, yielding program values. */
export class IteratorObj extends Opaque {
override readonly tag = "Iterator"
constructor(
proto: Obj,
readonly cursor: Cursor,
) {
super(proto)
}
override get describe() {
return "an iterator"
}
}
/** A built-in iterator over a host iterator, e.g. `array.values()`. */
export const hostIterator = (builtins: Builtins, iterator: Iterator<Value, undefined>): IteratorObj =>
new IteratorObj(builtins.Iterator, hostCursor(iterator))
/** A built-in object around a host value: data-like, so it prints as itself and crosses to extensions as a copy. */
export abstract class Wrapper extends Obj {
override inspect(item: (value: Value) => string) {
return this.toString()
}
}
export class DateObj extends Wrapper {
override readonly tag = "Date"
export class DateObj extends Obj {
constructor(
proto: Obj,
public time: number,
) {
super(proto)
}
override toString() {
return Number.isFinite(this.time) ? new Date(this.time).toISOString() : "Invalid Date"
}
override toPrimitive(hint: "default" | "number" | "string") {
return hint === "number" ? this.time : this.toString()
}
override toHost() {
return new Date(this.time)
}
}
export class RegExpObj extends Wrapper {
override readonly tag = "RegExp"
export class RegExpObj extends Obj {
readonly regex: RegExp
constructor(proto: Obj, pattern: string, flags: string) {
super(proto)
this.regex = new RegExp(pattern, flags)
}
override toString() {
return `/${this.regex.source}/${this.regex.flags}`
}
override toHost() {
return new RegExp(this.regex.source, this.regex.flags)
}
}
export class MapObj extends Wrapper {
override readonly tag = "Map"
readonly map = new Map<Value, Value>()
override inspect(item: (value: Value) => string) {
return `Map(${this.map.size}) [${[...this.map].map(([key, value]) => `[${item(key)},${item(value)}]`).join(",")}]`
}
override toHost(item: (value: Value) => unknown) {
return new Map([...this.map].map(([key, value]) => [item(key), item(value)]))
}
override iterator(builtins: Builtins) {
return this.map.entries().map((entry) => new Arr(builtins.Array, entry))
}
export class MapObj extends Obj {
readonly map = new Map<unknown, unknown>()
}
export class SetObj extends Wrapper {
override readonly tag = "Set"
readonly set = new Set<Value>()
override inspect(item: (value: Value) => string) {
return `Set(${this.set.size}) [${[...this.set].map(item).join(",")}]`
}
override toHost(item: (value: Value) => unknown) {
return new Set([...this.set].map(item))
}
override iterator() {
return this.set.values()
}
export class SetObj extends Obj {
readonly set = new Set<unknown>()
}
export class URLSearchParamsObj extends Wrapper {
override readonly tag = "URLSearchParams"
export class URLSearchParamsObj extends Obj {
constructor(
proto: Obj,
readonly params: URLSearchParams,
) {
super(proto)
}
override toString() {
return this.params.toString()
}
override toHost() {
return new URLSearchParams(this.params)
}
override iterator(builtins: Builtins) {
return this.params.entries().map((entry) => new Arr(builtins.Array, entry))
}
}
export class HeadersObj extends Wrapper {
override readonly tag = "Headers"
export class HeadersObj extends Obj {
constructor(
proto: Obj,
readonly headers: Headers,
) {
super(proto)
}
override inspect() {
return `Headers ${JSON.stringify(Object.fromEntries(this.headers))}`
}
override toHost() {
return new Headers(this.headers)
}
override iterator(builtins: Builtins) {
// Bun's Headers typings lack the iterator helpers, so the host iterator is lifted first.
return Iterator.from(this.headers.entries()).map((entry) => new Arr(builtins.Array, entry))
}
}
export class URLObj extends Wrapper {
override readonly tag = "URL"
export class URLObj extends Obj {
readonly searchParams: URLSearchParamsObj
constructor(
proto: Obj,
@@ -379,58 +175,30 @@ export class URLObj extends Wrapper {
super(proto)
this.searchParams = new URLSearchParamsObj(searchParamsProto, url.searchParams)
}
override toString() {
return this.url.href
}
override toHost() {
return new URL(this.url.href)
}
}
/** A `Uint8Array`: the host array does the byte clamping and ignores out-of-range writes, as JS does. */
export class Bytes extends Wrapper {
override readonly tag = "Uint8Array"
export class Bytes extends Obj {
constructor(
proto: Obj,
readonly bytes: Uint8Array,
) {
super(proto)
}
override toString() {
return this.bytes.join(",")
}
override inspect() {
return `Uint8Array(${this.bytes.length}) [${this.bytes.join(",")}]`
}
override toHost() {
return new Uint8Array(this.bytes)
}
override iterator() {
return this.bytes.values()
}
}
/** Every value a program can hold. Host values never appear here; they are copied in at the boundaries. */
export type Value = string | number | boolean | null | undefined | symbol | Obj | ToolReference
/** ToString without consulting program-defined methods. */
export const coerceToString = (value: Value): string => (value instanceof Obj ? value.toString() : String(value))
/** ToNumber without consulting program-defined methods; tool references are not numbers. */
export const coerceToNumber = (value: Value): number => {
if (value instanceof Obj) return value.toNumber()
return value instanceof ToolReference ? Number.NaN : Number(value)
}
/** ToIntegerOrInfinity: NaN is 0, fractions truncate. */
export const coerceToInteger = (value: Value): number => {
const number = coerceToNumber(value)
return Number.isNaN(number) ? 0 : Math.trunc(number)
}
/** Values that cannot cross the data boundary: opaque machinery and host-backed wrappers. */
export const isRuntimeReference = (value: Value): boolean =>
value instanceof Opaque || value instanceof Wrapper || value instanceof ToolReference
/** Built-in objects that wrap a host value; data-like, but never plain data. */
export const isWrapper = (
value: unknown,
): value is DateObj | RegExpObj | MapObj | SetObj | URLObj | URLSearchParamsObj | HeadersObj | Bytes =>
value instanceof DateObj ||
value instanceof RegExpObj ||
value instanceof MapObj ||
value instanceof SetObj ||
value instanceof URLObj ||
value instanceof URLSearchParamsObj ||
value instanceof HeadersObj ||
value instanceof Bytes
const MAX_ARRAY_INDEX = 4_294_967_295
@@ -448,7 +216,7 @@ type Indexed = Arr | Bytes
const isIndexed = (target: Obj): target is Indexed => target instanceof Arr || target instanceof Bytes
const elements = (target: Indexed): Array<Value> | Uint8Array => (target instanceof Arr ? target.items : target.bytes)
const elements = (target: Indexed): Array<unknown> | Uint8Array => (target instanceof Arr ? target.items : target.bytes)
const index = (target: Obj, key: string | symbol): number | undefined =>
isIndexed(target) && typeof key === "string" ? parseArrayIndex(key) : undefined
@@ -469,18 +237,18 @@ export const own = (target: Obj, key: PropertyKey): Slot | undefined => {
return target.props.get(name)
}
const read = (slot: Slot, receiver: Value): Value =>
const read = (slot: Slot, receiver: unknown): unknown =>
"value" in slot ? slot.value : slot.get === undefined ? undefined : slot.get(receiver)
export const hasOwn = (target: Obj, key: PropertyKey): boolean => own(target, key) !== undefined
export const getOwn = (target: Obj, key: PropertyKey): Value => {
export const getOwn = (target: Obj, key: PropertyKey): unknown => {
const slot = own(target, key)
return slot === undefined ? undefined : read(slot, target)
}
/** [[Get]]: walks the prototype chain; accessors see `receiver`, which is the primitive for wrapper prototypes. */
export const get = (target: Obj, key: PropertyKey, receiver: Value = target): Value => {
export const get = (target: Obj, key: PropertyKey, receiver: unknown = target): unknown => {
for (let current: Obj | null = target; current !== null; current = current.proto) {
const slot = own(current, key)
if (slot !== undefined) return read(slot, receiver)
@@ -495,14 +263,14 @@ export const has = (target: Obj, key: PropertyKey): boolean => {
return false
}
export const hasPrototype = (value: Value, proto: Obj): boolean => {
export const hasPrototype = (value: unknown, proto: Obj): boolean => {
for (let current = value instanceof Obj ? value.proto : null; current !== null; current = current.proto) {
if (current === proto) return true
}
return false
}
const writeElement = (target: Indexed, name: string | symbol, value: Value): boolean | undefined => {
const writeElement = (target: Indexed, name: string | symbol, value: unknown): boolean | undefined => {
const at = index(target, name)
if (at !== undefined) {
if (target instanceof Bytes) target.bytes[at] = typeof value === "number" ? value : Number(value)
@@ -518,7 +286,7 @@ const writeElement = (target: Indexed, name: string | symbol, value: Value): boo
}
/** [[Set]]: an inherited setter or read-only property decides before an own data property is created. */
export const set = (target: Obj, key: PropertyKey, value: Value): boolean => {
export const set = (target: Obj, key: PropertyKey, value: unknown): boolean => {
const name = canonical(key)
for (let current: Obj | null = target; current !== null; current = current.proto) {
const slot = own(current, name)
@@ -546,7 +314,7 @@ export const set = (target: Obj, key: PropertyKey, value: Value): boolean => {
}
/** [[DefineOwnProperty]] for a data property, ignoring the chain. */
export const define = (target: Obj, key: PropertyKey, value: Value, attrs: Attributes = data): void => {
export const define = (target: Obj, key: PropertyKey, value: unknown, attrs: Attributes = data): void => {
const name = canonical(key)
if (isIndexed(target) && writeElement(target, name, value) !== undefined) return
target.props.set(name, { value, ...attrs })
@@ -597,9 +365,9 @@ export const keys = (target: Obj): Array<string> =>
ownKeys(target).filter((key): key is string => typeof key === "string" && enumerable(target, key))
/** Own enumerable string entries: `Object.entries` and serialization. */
export const entries = (target: Obj): Array<[string, Value]> => keys(target).map((key) => [key, getOwn(target, key)])
export const entries = (target: Obj): Array<[string, unknown]> => keys(target).map((key) => [key, getOwn(target, key)])
export const record = (proto: Obj, fields: Record<string, Value>): Obj => {
export const record = (proto: Obj, fields: Record<string, unknown>): Obj => {
const target = new Obj(proto)
for (const [key, value] of Object.entries(fields)) define(target, key, value)
return target
+29 -53
View File
@@ -2,15 +2,15 @@ import { Cause, Deferred, Effect, Exit, Fiber, Scope } from "effect"
import type { Diagnostic } from "../codemode.js"
import { MAX_PENDING_PROMISES } from "./limits.js"
import { CallSite, Throw, rangeError, typeError } from "./model.js"
import { Callable, define, get, hidden, Arr, Fn, Obj, PromiseObj, record, type Value } from "./objects.js"
import { Callable, define, get, hidden, Arr, Fn, Obj, PromiseObj, record } from "./objects.js"
import { constructor, fn, methods, native, receiver, requiresNew } from "./native.js"
import { createAggregateErrorValue, locate, materialize, normalizeError } from "./errors.js"
import { describeValue, typeofValue } from "./references.js"
import { typeofValue } from "./references.js"
import { applyCollectionCallback, isSupportedCallback } from "./callback.js"
import type { Interpreter } from "./interpreter.js"
// A `resolve`/`reject` handed to an executor or thenable: calling it settles the capability.
const capability = <R>(ctx: Interpreter<R>, name: string, settle: (value: Value) => void) =>
const capability = <R>(ctx: Interpreter<R>, name: string, settle: (value: unknown) => void) =>
fn(ctx.builtins, name, 1, (_, args) => {
settle(args[0])
return undefined
@@ -31,7 +31,7 @@ export class Pending<R> {
// Resolution bodies need the promise's own identity to reject `resolve(promise)` self-resolution.
createWithSelf(
body: (self: { promise?: PromiseObj }) => Effect.Effect<Value, unknown, R>,
body: (self: { promise?: PromiseObj }) => Effect.Effect<unknown, unknown, R>,
): Effect.Effect<PromiseObj, never, R> {
const self: { promise?: PromiseObj } = {}
return Effect.map(this.create(body(self)), (promise) => {
@@ -40,7 +40,7 @@ export class Pending<R> {
})
}
create(effect: Effect.Effect<Value, unknown, R>): Effect.Effect<PromiseObj, never, R> {
create(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<PromiseObj, never, R> {
return Effect.flatMap(CallSite, (site) => {
if (this.active.size >= MAX_PENDING_PROMISES) {
throw rangeError(
@@ -79,7 +79,7 @@ export class Pending<R> {
if (id !== undefined) this.failures.delete(id)
}
await(promise: PromiseObj): Effect.Effect<Exit.Exit<Value, unknown>> {
await(promise: PromiseObj): Effect.Effect<Exit.Exit<unknown, unknown>> {
return Fiber.await(promise.fiber)
}
@@ -105,9 +105,9 @@ export class Pending<R> {
export const resolvePromiseValue = <R>(
ctx: Interpreter<R>,
value: Value,
value: unknown,
own?: { promise?: PromiseObj },
): Effect.Effect<Value, unknown, R> => {
): Effect.Effect<unknown, unknown, R> => {
if (own?.promise !== undefined && value === own.promise) {
return Effect.die(typeError("Chaining cycle detected: a promise cannot resolve with itself."))
}
@@ -119,7 +119,7 @@ export const resolvePromiseValue = <R>(
return Effect.gen(function* () {
// Promise resolution invokes a thenable's method in a later job.
yield* Effect.yieldNow
const deferred = Deferred.makeUnsafe<Value, unknown>()
const deferred = Deferred.makeUnsafe<unknown, unknown>()
const resolve = capability(ctx, "resolve", (result) => Deferred.doneUnsafe(deferred, Exit.succeed(result)))
const reject = capability(ctx, "reject", (reason) => Deferred.doneUnsafe(deferred, Exit.fail(new Throw(reason))))
const executed = yield* Effect.exit(ctx.call(then, value, [resolve, reject]))
@@ -131,46 +131,29 @@ export const resolvePromiseValue = <R>(
})
}
export const resolvePromise = <R>(ctx: Interpreter<R>, value: Value): Effect.Effect<PromiseObj, never, R> => {
export const resolvePromise = <R>(ctx: Interpreter<R>, value: unknown): Effect.Effect<PromiseObj, never, R> => {
if (value instanceof PromiseObj) return Effect.succeed(value)
return ctx.pending.createWithSelf((self) => resolvePromiseValue(ctx, value, self))
}
const promiseStatics = ["all", "allSettled", "race", "any", "resolve", "reject", "withResolvers", "try"] as const
const promiseStatics = ["all", "allSettled", "race", "any", "resolve", "reject"] as const
const invokePromiseMethod = <R>(
ctx: Interpreter<R>,
name: (typeof promiseStatics)[number],
args: Array<Value>,
): Effect.Effect<Value, unknown, R> => {
args: Array<unknown>,
): Effect.Effect<unknown, unknown, R> => {
if (name === "resolve") {
return resolvePromise(ctx, args[0])
}
if (name === "reject") {
return ctx.pending.create(Effect.fail(new Throw(args[0])))
}
if (name === "withResolvers") {
return Effect.map(promiseCapability(ctx), (made) =>
record(ctx.builtins.Object, { promise: made.promise, resolve: made.resolve, reject: made.reject }),
)
}
if (name === "try") {
if (typeofValue(args[0]) !== "function") {
throw typeError(`Promise.try expects a function, received ${describeValue(args[0])}.`)
}
return Effect.flatMap(Effect.exit(ctx.call(args[0], undefined, args.slice(1))), (called) => {
if (Exit.isSuccess(called)) return resolvePromise(ctx, called.value)
if (Cause.hasInterruptsOnly(called.cause)) return Effect.failCause(called.cause)
return ctx.pending.create(Effect.fail(Cause.squash(called.cause)))
})
}
return ctx.pending.create(
Effect.gen(function* () {
const cursor = yield* ctx.iterate(args[0])
if (cursor === undefined) {
throw typeError(`Promise.${name} expects a synchronous iterable, received ${describeValue(args[0])}.`)
}
if (cursor === undefined) throw typeError(`Promise.${name} expects an array or other synchronous iterable.`)
const items: Array<PromiseObj> = []
while (true) {
const step = yield* cursor.next
@@ -192,7 +175,7 @@ const invokePromiseMethod = <R>(
)
}
if (name === "allSettled") {
const outcomes: Array<Value> = []
const outcomes: Array<unknown> = []
for (const item of items) {
const exit = yield* ctx.pending.await(item)
if (Exit.isSuccess(exit)) {
@@ -240,8 +223,8 @@ const invokePromiseMethod = <R>(
const instanceMethod = <R>(
ctx: Interpreter<R>,
name: "then" | "catch" | "finally",
thisValue: Value,
args: Array<Value>,
thisValue: unknown,
args: Array<unknown>,
): Effect.Effect<PromiseObj, unknown, R> => {
const method = `Promise.prototype.${name}`
const promise = receiver(PromiseObj, thisValue, method)
@@ -254,30 +237,23 @@ const instanceMethod = <R>(
return chainReaction(ctx, promise, onFulfilled, onRejected, method)
}
/** NewPromiseCapability: a pending promise with the resolve/reject callables that settle it exactly once. */
const promiseCapability = <R>(ctx: Interpreter<R>) =>
Effect.gen(function* () {
const deferred = Deferred.makeUnsafe<Value, unknown>()
const constructPromise = <R>(ctx: Interpreter<R>, executor: unknown): Effect.Effect<PromiseObj, unknown, R> => {
if (!(executor instanceof Fn)) {
throw typeError("new Promise(...) expects an executor function (e.g. new Promise((resolve, reject) => { ... })).")
}
return Effect.gen(function* () {
const deferred = Deferred.makeUnsafe<unknown, unknown>()
const promise = yield* ctx.pending.createWithSelf((self) =>
Effect.flatMap(Deferred.await(deferred), (value) => resolvePromiseValue(ctx, value, self)),
)
const resolve = capability(ctx, "resolve", (value) => Deferred.doneUnsafe(deferred, Exit.succeed(value)))
const reject = capability(ctx, "reject", (value) => Deferred.doneUnsafe(deferred, Exit.fail(new Throw(value))))
return { promise, resolve, reject, deferred }
})
const constructPromise = <R>(ctx: Interpreter<R>, executor: Value): Effect.Effect<PromiseObj, unknown, R> => {
if (!(executor instanceof Fn)) {
throw typeError("new Promise(...) expects an executor function (e.g. new Promise((resolve, reject) => { ... })).")
}
return Effect.gen(function* () {
const made = yield* promiseCapability(ctx)
const executed = yield* Effect.exit(ctx.call(executor, undefined, [made.resolve, made.reject]))
const executed = yield* Effect.exit(ctx.call(executor, undefined, [resolve, reject]))
if (!Exit.isSuccess(executed)) {
if (Cause.hasInterruptsOnly(executed.cause)) return yield* Effect.failCause(executed.cause)
Deferred.doneUnsafe(made.deferred, Exit.fail(Cause.squash(executed.cause)))
Deferred.doneUnsafe(deferred, Exit.fail(Cause.squash(executed.cause)))
}
return made.promise
return promise
})
}
@@ -286,10 +262,10 @@ const settleAfterTurn = <A, E, R>(body: Effect.Effect<A, E, R>): Effect.Effect<A
Effect.flatMap(Effect.exit(body), (exit) => Effect.andThen(Effect.yieldNow, exit))
class PromiseAnyFulfilled {
constructor(readonly value: Value) {}
constructor(readonly value: unknown) {}
}
const reactionHandler = (value: Value, method: string): Callable | undefined => {
const reactionHandler = (value: unknown, method: string): Callable | undefined => {
if (isSupportedCallback(value)) return value
if (typeofValue(value) === "function") {
throw typeError(
@@ -303,7 +279,7 @@ const reactionHandler = (value: Value, method: string): Callable | undefined =>
const reactionExit = <R>(
ctx: Interpreter<R>,
source: PromiseObj,
): Effect.Effect<Exit.Exit<Value, unknown>, unknown, R> =>
): Effect.Effect<Exit.Exit<unknown, unknown>, unknown, R> =>
Effect.gen(function* () {
const exit = yield* ctx.pending.await(source)
if (!Exit.isSuccess(exit) && Cause.hasInterruptsOnly(exit.cause)) return yield* Effect.failCause(exit.cause)
+57 -21
View File
@@ -1,18 +1,45 @@
import { ToolReference } from "../tool-runtime.js"
import { invalidData } from "./model.js"
import { Callable, getOwn, isRuntimeReference, Obj, Opaque, ownKeys, type Value } from "./objects.js"
import {
Callable,
getOwn,
isWrapper,
ownKeys,
Arr,
Bytes,
DateObj,
GeneratorObj,
MapObj,
Obj,
PromiseObj,
RegExpObj,
SetObj,
URLObj,
URLSearchParamsObj,
HeadersObj,
} from "./objects.js"
/** Interpreter machinery that is never data, unlike a Date or Map, which cross some boundaries as copies. */
export const isOpaque = (value: Value): boolean => value instanceof Opaque || value instanceof ToolReference
/** Values that cannot cross the data boundary. */
export const isRuntimeReference = (value: unknown): boolean =>
value instanceof Callable ||
value instanceof GeneratorObj ||
value instanceof ToolReference ||
value instanceof PromiseObj ||
isWrapper(value)
function* childValues(value: object): Generator {
if (!(value instanceof Obj)) return
for (const key of ownKeys(value)) yield getOwn(value, key)
}
// Depth-first search over a value tree. `match` stops the walk; `skip` prunes a subtree without matching it.
const find = (
value: Value,
match: (current: Value) => boolean,
skip: (current: Value) => boolean,
value: unknown,
match: (current: unknown) => boolean,
skip: (current: unknown) => boolean,
seen: Set<object>,
): boolean => {
const pending: Array<Iterator<Value>> = [[value].values()]
const pending: Array<Iterator<unknown>> = [[value].values()]
while (pending.length > 0) {
const next = pending.at(-1)!.next()
if (next.done) {
@@ -21,28 +48,25 @@ const find = (
}
const current = next.value
if (match(current)) return true
if (!(current instanceof Obj) || skip(current) || seen.has(current)) continue
if (current === null || typeof current !== "object" || skip(current) || seen.has(current)) continue
seen.add(current)
pending.push(
ownKeys(current)
.map((key) => getOwn(current, key))
.values(),
)
pending.push(childValues(current))
}
return false
}
const never = () => false
export const containsRuntimeReference = (value: Value): boolean => find(value, isRuntimeReference, never, new Set())
export const containsRuntimeReference = (value: unknown): boolean => find(value, isRuntimeReference, never, new Set())
export const containsOpaqueReference = (value: Value): boolean =>
find(value, isOpaque, (current) => isRuntimeReference(current) && !isOpaque(current), new Set())
// Wrapper values are data here, not opaque interpreter references.
export const containsOpaqueReference = (value: unknown): boolean =>
find(value, (current) => !isWrapper(current) && isRuntimeReference(current), isWrapper, new Set())
// Reject cycles before mutation so later boundary walks remain safe.
export const rejectCircularInsertion = (
container: Obj,
value: Value,
container: object,
value: unknown,
label: string,
seen = new Set<object>(),
): void => {
@@ -51,14 +75,26 @@ export const rejectCircularInsertion = (
}
}
export const describeValue = (value: Value): string => {
export const describeValue = (value: unknown): string => {
if (value === null || value === undefined) return String(value)
if (value instanceof Obj) return value.describe
if (value instanceof Arr) return "an array"
if (value instanceof PromiseObj) return "an un-awaited Promise"
if (value instanceof ToolReference) return "a tool reference"
if (value instanceof DateObj) return "a Date"
if (value instanceof RegExpObj) return "a RegExp"
if (value instanceof MapObj) return "a Map"
if (value instanceof SetObj) return "a Set"
if (value instanceof URLObj) return "a URL"
if (value instanceof URLSearchParamsObj) return "a URLSearchParams"
if (value instanceof HeadersObj) return "a Headers"
if (value instanceof Bytes) return "a Uint8Array"
if (value instanceof GeneratorObj) return "a generator"
if (isRuntimeReference(value)) return "a function"
if (typeof value === "object") return "a data object"
return `a ${typeof value}`
}
export const typeofValue = (value: Value): string => {
export const typeofValue = (value: unknown): string => {
if (value instanceof Callable) return "function"
if (value instanceof ToolReference) return value.path.length > 0 ? "function" : "object"
return typeof value

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