mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-20 15:47:38 +00:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fa63c521d | ||
|
|
eb0e26b974 | ||
|
|
6f2b0e7833 | ||
|
|
f153255942 | ||
|
|
fef2fad76f | ||
|
|
f30d06ea34 | ||
|
|
dfa44e94e8 | ||
|
|
b81e10a461 | ||
|
|
65c93b69ed | ||
|
|
b073b052d3 | ||
|
|
728b2b6052 | ||
|
|
cc3ce20ab4 | ||
|
|
4ad5001be2 | ||
|
|
14b3c2ea4b | ||
|
|
558bd54c9b | ||
|
|
7e70f7e1ab | ||
|
|
cb6d95b7ef | ||
|
|
7020944359 | ||
|
|
4b00dd2713 | ||
|
|
b447627f5f | ||
|
|
5848ee0d24 | ||
|
|
839aa25c3e | ||
|
|
c42f1c9232 | ||
|
|
417f6d234d | ||
|
|
45a13af0ee | ||
|
|
e50d845451 | ||
|
|
56816621f8 | ||
|
|
7125f5f8b5 | ||
|
|
01a6ed8d97 | ||
|
|
004583d598 |
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@opencode/ai",
|
||||
"version": "2.0.8",
|
||||
"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.8",
|
||||
"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.8",
|
||||
"version": "2.0.10",
|
||||
"bin": {
|
||||
"opencode2": "./bin/opencode2.cjs",
|
||||
},
|
||||
@@ -175,7 +175,7 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@opencode/protocol": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -201,11 +201,10 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
@@ -215,7 +214,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -251,7 +250,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -278,7 +277,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -295,7 +294,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -319,7 +318,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -339,7 +338,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode/core",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
@@ -407,7 +406,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode/desktop",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"electron-context-menu": "4.1.2",
|
||||
@@ -456,7 +455,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/sdk": "1.18.21",
|
||||
@@ -493,7 +492,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -509,7 +508,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode/http-recorder",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
},
|
||||
@@ -528,7 +527,7 @@
|
||||
},
|
||||
"packages/httpapi-codegen": {
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
@@ -541,7 +540,7 @@
|
||||
},
|
||||
"packages/latex": {
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -555,7 +554,7 @@
|
||||
},
|
||||
"packages/merman": {
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -570,7 +569,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode/ai": "workspace:*",
|
||||
@@ -609,7 +608,7 @@
|
||||
},
|
||||
"packages/plugin-browser": {
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -639,7 +638,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@opencode/schema": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -654,7 +653,7 @@
|
||||
},
|
||||
"packages/schema": {
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -678,7 +677,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@opencode/sdk",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -699,7 +698,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
@@ -721,7 +720,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
@@ -756,7 +755,7 @@
|
||||
},
|
||||
"packages/simulation": {
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@opencode/ai": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -776,7 +775,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -810,7 +809,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -829,7 +828,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -875,7 +874,7 @@
|
||||
},
|
||||
"packages/theme": {
|
||||
"name": "@opencode/theme",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@opentui/core": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -889,7 +888,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode/tui",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -924,7 +923,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -959,7 +958,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode/util",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -992,7 +991,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode/web",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1033,7 +1032,7 @@
|
||||
},
|
||||
"services/update": {
|
||||
"name": "@opencode/update",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"jose": "6.0.11",
|
||||
"semver": "catalog:",
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-W2QBi7/jtbvLp3Gj+nR49+wqVgCB0qfnQ30C0PQOjO4=",
|
||||
"aarch64-linux": "sha256-R4YKo8JUvKd77QYehTNpj/65oodeLBQ8mdxLzBLrFdw=",
|
||||
"aarch64-darwin": "sha256-7v2ABLG6zsIMSzsYu06BWEqbLL4HOeKyZFBx/gx87SI=",
|
||||
"x86_64-darwin": "sha256-emKRoQILMzB5ko45dtMPuZGV2pAyszcwzKUh9ey9JZM="
|
||||
"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
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"name": "@opencode/ai",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -73,11 +73,6 @@ 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) => event.code || event.error?.code || event.response?.error?.code || undefined
|
||||
const code = (event: OpenResponses.Event) => OpenResponses.errorDetail(event).code
|
||||
|
||||
const rejected = (
|
||||
observation: Extract<ChannelObservation, { readonly type: "provider-failure" }>,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Effect, Option, Schema, SchemaGetter } from "effect"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import type { Content } from "@opencode/schema/tool"
|
||||
import { HttpTransport } from "../route/transport/index.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
@@ -333,53 +333,13 @@ export const StreamItem = Schema.StructWithRest(
|
||||
export type StreamItem = Schema.Schema.Type<typeof StreamItem>
|
||||
export type OutputItem = StreamItem & { readonly id: string }
|
||||
|
||||
// Responses-compatible providers put streaming error details at the top level or
|
||||
// under `error`, and response failures under `response.error`. Accept all three shapes.
|
||||
// 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.
|
||||
// https://www.openresponses.org/specification
|
||||
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,
|
||||
})
|
||||
const asText = (value: unknown) =>
|
||||
typeof value === "string" && value.length > 0 ? value : typeof value === "number" ? String(value) : undefined
|
||||
|
||||
export const Event = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
@@ -400,31 +360,18 @@ 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: optionalNull(OpenResponsesErrorPayload),
|
||||
error: Schema.optional(Schema.Unknown),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
),
|
||||
),
|
||||
code: optionalNull(Schema.String),
|
||||
message: Schema.optional(Schema.String),
|
||||
param: optionalNull(Schema.String),
|
||||
error: optionalNull(OpenResponsesErrorPayload),
|
||||
code: Schema.optional(Schema.Unknown),
|
||||
message: Schema.optional(Schema.Unknown),
|
||||
error: Schema.optional(Schema.Unknown),
|
||||
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 }
|
||||
@@ -433,16 +380,15 @@ const decodeEventValue = Schema.decodeUnknownEffect(Event)
|
||||
const decodeFrame = Schema.decodeUnknownEffect(ProviderShared.Json)
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export const decodeChannelEvent = (frame: string) =>
|
||||
decodeFrame(frame).pipe(
|
||||
Effect.flatMap((value) =>
|
||||
decodeEventValue(
|
||||
ProviderShared.isRecord(value) &&
|
||||
value.type === undefined &&
|
||||
(typeof value.error === "string" || ProviderShared.isRecord(value.error))
|
||||
ProviderShared.isRecord(value) && value.type === undefined && value.error != null
|
||||
? { ...value, type: "error" }
|
||||
: value,
|
||||
),
|
||||
@@ -1422,22 +1368,21 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
|
||||
return [{ ...current, lifecycle }, events] satisfies StepResult
|
||||
})
|
||||
|
||||
// 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
|
||||
/** 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),
|
||||
}
|
||||
}
|
||||
|
||||
// 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 nested = event.error ?? event.response?.error ?? undefined
|
||||
const summary = providerErrorMessage(event, nested)
|
||||
const detail = errorDetail(event)
|
||||
const summary = detail.message && detail.code ? `${detail.code}: ${detail.message}` : (detail.message ?? detail.code)
|
||||
const message = summary ?? (body === "{}" ? fallback : body)
|
||||
const status =
|
||||
typeof event.status === "number"
|
||||
@@ -1520,18 +1465,7 @@ 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 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`)),
|
||||
)
|
||||
if (event.type === "error") return providerFailure(event, `${state.name} stream error`)
|
||||
return Effect.succeed<StepResult>([state, NO_EVENTS])
|
||||
}
|
||||
|
||||
|
||||
@@ -215,7 +215,9 @@ export const fromWebSocket = (
|
||||
): Effect.Effect<WebSocketConnection, AIError> =>
|
||||
Effect.gen(function* () {
|
||||
yield* waitOpen(ws, input)
|
||||
const messages = yield* Queue.bounded<string | Uint8Array, AIError | Cause.Done<void>>(128)
|
||||
// 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 oversized = (message: string | Uint8Array) =>
|
||||
typeof message === "string" ? new Blob([message]).size > MAX_FRAME_BYTES : message.byteLength > MAX_FRAME_BYTES
|
||||
@@ -238,19 +240,7 @@ export const fromWebSocket = (
|
||||
}
|
||||
const offer = (message: string | Uint8Array) => {
|
||||
if (rejectOversized(message)) return
|
||||
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",
|
||||
}),
|
||||
),
|
||||
)
|
||||
Queue.offerUnsafe(messages, message)
|
||||
}
|
||||
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
|
||||
@@ -11,66 +11,78 @@ import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
|
||||
|
||||
it.effect("normalizes flat errors in shared SSE and WebSocket decoding", () =>
|
||||
it.effect("decodes error frames verbatim 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]) {
|
||||
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,
|
||||
for (const frame of [
|
||||
{ type: "error", sequence_number: 4, code: "server_shutting_down", message: "Shutting down", param: null },
|
||||
{ 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(unchanged))).toEqual(unchanged)
|
||||
expect(yield* decode(JSON.stringify(frame))).toEqual(frame)
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues to normalize untyped xAI WebSocket errors", () =>
|
||||
it.effect("reads bare WebSocket error envelopes as error events", () =>
|
||||
Effect.gen(function* () {
|
||||
const message = "gRPC error: Response with id=resp_missing not found"
|
||||
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,
|
||||
})
|
||||
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()
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("normalizes string errors in shared SSE and WebSocket decoding", () =>
|
||||
it.effect("extracts error details from every shape and falls back to the raw frame", () =>
|
||||
Effect.gen(function* () {
|
||||
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" } },
|
||||
})
|
||||
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 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")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test"
|
||||
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true })
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
pageMessages,
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ directory, server, sessions }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(sessions.map((session) => ({ type: "session", server, sessionId: session.id }))),
|
||||
)
|
||||
},
|
||||
{ directory: fixture.directory, server: fixture.serverKey, sessions: fixture.sessions },
|
||||
)
|
||||
await page.goto("/")
|
||||
await page.locator('[data-slot="mobile-tabs-trigger"]').click()
|
||||
await expect(page.locator('[data-slot="mobile-tabs-drawer"]')).toBeVisible()
|
||||
const drawer = page.locator('[data-slot="mobile-drawer-content"]')
|
||||
await expect
|
||||
.poll(() => drawer.evaluate((element) => new DOMMatrixReadOnly(getComputedStyle(element).transform).m42))
|
||||
.toBe(0)
|
||||
await expect(drawer).not.toHaveAttribute("data-transitioning")
|
||||
})
|
||||
|
||||
test("reorders session tabs with touch", async ({ page }) => {
|
||||
const tabs = page.locator('[data-slot="vertical-tabs"] a')
|
||||
await expect(tabs).toContainText([fixture.expected.sourceTitle, fixture.expected.targetTitle])
|
||||
|
||||
const target = tabs.filter({ hasText: fixture.expected.targetTitle })
|
||||
const source = tabs.filter({ hasText: fixture.expected.sourceTitle })
|
||||
const targetBox = await target.boundingBox()
|
||||
const sourceBox = await source.boundingBox()
|
||||
expect(targetBox).not.toBeNull()
|
||||
expect(sourceBox).not.toBeNull()
|
||||
|
||||
await touchDrag(page, source, {
|
||||
from: { x: sourceBox!.x + sourceBox!.width / 2, y: sourceBox!.y + sourceBox!.height / 2 },
|
||||
to: { x: targetBox!.x + targetBox!.width / 2, y: targetBox!.y + targetBox!.height / 2 },
|
||||
})
|
||||
|
||||
await expect(tabs).toContainText([fixture.expected.targetTitle, fixture.expected.sourceTitle])
|
||||
})
|
||||
|
||||
test("dismisses a touch context menu by tapping outside", async ({ page }) => {
|
||||
const drawer = page.locator('[data-slot="mobile-tabs-drawer"]')
|
||||
const tab = drawer.locator('[data-slot="tab-link"]').filter({ hasText: fixture.expected.sourceTitle })
|
||||
const home = drawer.getByRole("button", { name: "Home", exact: true })
|
||||
const homeBox = await home.boundingBox()
|
||||
const box = await tab.boundingBox()
|
||||
expect(homeBox).not.toBeNull()
|
||||
expect(box).not.toBeNull()
|
||||
const point = { x: box!.x + box!.width / 2, y: box!.y + box!.height / 2 }
|
||||
|
||||
await tab.dispatchEvent("pointerdown", {
|
||||
pointerType: "touch",
|
||||
pointerId: 1,
|
||||
isPrimary: true,
|
||||
clientX: point.x,
|
||||
clientY: point.y,
|
||||
})
|
||||
const rename = page.getByRole("menuitem", { name: "Rename", exact: true })
|
||||
await expect(rename).toBeVisible()
|
||||
await expect(drawer).toBeVisible()
|
||||
await tab.filter({ visible: true }).dispatchEvent("pointerup", {
|
||||
pointerType: "touch",
|
||||
pointerId: 1,
|
||||
isPrimary: true,
|
||||
clientX: point.x,
|
||||
clientY: point.y,
|
||||
})
|
||||
|
||||
await page.touchscreen.tap(homeBox!.x + homeBox!.width / 2, homeBox!.y + homeBox!.height / 2)
|
||||
await expect(rename).toBeHidden()
|
||||
await expect(drawer).toBeVisible()
|
||||
})
|
||||
|
||||
async function touchDrag(
|
||||
page: Page,
|
||||
source: Locator,
|
||||
input: { from: { x: number; y: number }; to: { x: number; y: number } },
|
||||
) {
|
||||
const client = await page.context().newCDPSession(page)
|
||||
await client.send("Input.dispatchTouchEvent", {
|
||||
type: "touchStart",
|
||||
touchPoints: [{ ...input.from, id: 1 }],
|
||||
})
|
||||
await client.send("Input.dispatchTouchEvent", {
|
||||
type: "touchMove",
|
||||
touchPoints: [{ x: (input.from.x + input.to.x) / 2, y: (input.from.y + input.to.y) / 2, id: 1 }],
|
||||
})
|
||||
await expect(source).toHaveCount(2)
|
||||
await client.send("Input.dispatchTouchEvent", {
|
||||
type: "touchMove",
|
||||
touchPoints: [{ ...input.to, id: 1 }],
|
||||
})
|
||||
await client.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] })
|
||||
}
|
||||
@@ -166,6 +166,45 @@ test.describe("session timeline projection", () => {
|
||||
await expect(longNotice.locator("[title]")).toHaveAttribute("title", `Switched to ${longName}`)
|
||||
await expect.poll(() => longNotice.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
|
||||
})
|
||||
|
||||
test("does not repeat a review comment file as an attachment", async ({ page }) => {
|
||||
const message = userMessage([
|
||||
userText("what's goin on here", { id: "prt_user_review_comment" }),
|
||||
{
|
||||
id: "prt_user_review_file",
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
filename: "LiveRuntime.ts",
|
||||
url: "file:///repo/LiveRuntime.ts?start=14&end=32",
|
||||
},
|
||||
{
|
||||
id: "prt_user_unrelated_file",
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
filename: "notes.txt",
|
||||
url: "data:text/plain;base64,bm90ZXM=",
|
||||
},
|
||||
])
|
||||
message.metadata = {
|
||||
displayText: "what's goin on here",
|
||||
comments: [
|
||||
{
|
||||
path: "LiveRuntime.ts",
|
||||
comment: "what's goin on here",
|
||||
selection: { startLine: 14, startChar: 0, endLine: 32, endChar: 0 },
|
||||
origin: "review",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
await setupTimeline(page, { messages: [message, assistantMessage()] })
|
||||
|
||||
const user = page.locator('[data-component="user-message"]')
|
||||
await expect(user.getByText("LiveRuntime.ts:14-32", { exact: true })).toBeVisible()
|
||||
const attachments = user.locator('[data-slot="user-message-attachments"]')
|
||||
await expect(attachments.getByText("LiveRuntime.ts", { exact: true })).toHaveCount(0)
|
||||
await expect(attachments.getByText("notes.txt", { exact: true })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
function patchFile(file: string, status: "added" | "modified" | "deleted") {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { For, Show, createEffect, createMemo, lazy, on, onCleanup } from "solid-js"
|
||||
import { For, Show, createEffect, createMemo, on, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { TextInput } from "@opencode/ui/text-input"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
@@ -17,8 +18,6 @@ import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
|
||||
const Icon = lazy(() => import("@opencode/ui/icon").then((module) => ({ default: module.Icon })))
|
||||
|
||||
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
||||
const PALETTE_ID = "command.palette"
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { canOpenTabRename, canStartTabDrag, forwardTabRef, isTabCloseTarget } from "./tab-gesture"
|
||||
import { canOpenTabRename, forwardTabRef, isTabCloseTarget } from "./tab-gesture"
|
||||
|
||||
describe("titlebar tab gestures", () => {
|
||||
test("excludes close controls from tab gestures", () => {
|
||||
@@ -24,10 +24,4 @@ describe("titlebar tab gestures", () => {
|
||||
expect(canOpenTabRename(false, false, false)).toBe(true)
|
||||
expect(canOpenTabRename(false, false, true)).toBe(false)
|
||||
})
|
||||
|
||||
test("preserves native panning for touch pointers", () => {
|
||||
expect(canStartTabDrag("mouse")).toBe(true)
|
||||
expect(canStartTabDrag("pen")).toBe(true)
|
||||
expect(canStartTabDrag("touch")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,10 +4,6 @@ export function isTabCloseTarget(target: EventTarget | null) {
|
||||
return target instanceof Element && !!target.closest('[data-slot="tab-close"]')
|
||||
}
|
||||
|
||||
export function canStartTabDrag(pointerType: string) {
|
||||
return pointerType !== "touch"
|
||||
}
|
||||
|
||||
export function forwardTabRef(ref: Ref<HTMLDivElement> | undefined, element: HTMLDivElement) {
|
||||
if (typeof ref === "function") ref(element)
|
||||
}
|
||||
|
||||
@@ -328,6 +328,7 @@ export function TabNavItem(props: {
|
||||
|
||||
return (
|
||||
<Menu.Context
|
||||
modal={false}
|
||||
onOpenChange={(open) => {
|
||||
setMenu("open", open)
|
||||
if (open) setPopoverOpen(false)
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { createTabComposerState } from "@/composer/persistence"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { canStartTabDrag, isTabCloseTarget } from "./tab-gesture"
|
||||
import { isTabCloseTarget } from "./tab-gesture"
|
||||
import { adjacentTabKey, mergeVisibleTabOrder } from "./tab-order"
|
||||
import type { SessionInfo } from "@opencode/client/promise"
|
||||
|
||||
@@ -292,9 +292,11 @@ export function TitlebarTabStrip(props: {
|
||||
<DragDropProvider
|
||||
sensors={[
|
||||
PointerSensor.configure({
|
||||
activationConstraints: [new PointerActivationConstraints.Distance({ value: 4 })],
|
||||
activationConstraints: (event) =>
|
||||
event.pointerType === "touch"
|
||||
? [new PointerActivationConstraints.Distance({ value: 8 })]
|
||||
: [new PointerActivationConstraints.Distance({ value: 4 })],
|
||||
preventActivation: (event) =>
|
||||
!canStartTabDrag(event.pointerType) ||
|
||||
isTabCloseTarget(event.target) ||
|
||||
(event.target instanceof Element && !!event.target.closest('[contenteditable="true"]')),
|
||||
}),
|
||||
@@ -313,6 +315,7 @@ export function TitlebarTabStrip(props: {
|
||||
if (!source) return
|
||||
const tab = props.tabs.find((item) => tabKey(item) === source.id.toString())
|
||||
if (!tab) return
|
||||
if (vertical()) return
|
||||
const tabEl = source.element?.querySelector<HTMLDivElement>("[data-titlebar-tab]")
|
||||
props.onNavigate(tab, tabEl ?? undefined)
|
||||
}}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env bun
|
||||
import { Script } from "@opencode/script"
|
||||
import { $ } from "bun"
|
||||
import { mkdir, rm } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { UpdateArtifact } from "../../../script/update-artifact"
|
||||
|
||||
if (Script.channel !== "beta" && Script.channel !== "latest") {
|
||||
throw new Error("Homebrew publishing requires the beta or latest channel")
|
||||
}
|
||||
const valid =
|
||||
Script.channel === "beta"
|
||||
? /^\d+\.\d+\.\d+-beta[.-]\d+(?:\.\d+)?$/.test(Script.version)
|
||||
: /^\d+\.\d+\.\d+$/.test(Script.version)
|
||||
if (!valid) throw new Error(`Expected a ${Script.channel} release version`)
|
||||
|
||||
const dir = fileURLToPath(new URL("..", import.meta.url))
|
||||
const root = path.resolve(process.env.OPENCODE_CLI_DIST ?? path.join(dir, "dist"))
|
||||
const outdir = path.join(root, "homebrew-tap")
|
||||
const dryRun = process.argv.includes("--dry-run")
|
||||
const name = Script.channel === "beta" ? "opencode-beta" : "opencode-v2"
|
||||
const formulaClass = Script.channel === "beta" ? "OpencodeBeta" : "OpencodeV2"
|
||||
|
||||
const targets = await Promise.all(
|
||||
[
|
||||
{ name: "darwin-arm64", archive: "zip" },
|
||||
{ name: "darwin-x64-baseline", archive: "zip" },
|
||||
{ name: "linux-arm64", archive: "tar.gz" },
|
||||
{ name: "linux-x64-baseline", archive: "tar.gz" },
|
||||
].map(async (target) => {
|
||||
const filename = `opencode-${target.name}.${target.archive}`
|
||||
const file = Bun.file(path.join(root, filename))
|
||||
if (!(await file.exists()) || !file.size) throw new Error(`Missing Homebrew archive: ${filename}`)
|
||||
const sha256 = new Bun.CryptoHasher("sha256")
|
||||
for await (const chunk of file.stream()) sha256.update(chunk)
|
||||
return {
|
||||
...target,
|
||||
url: `https://opencode.ai/files/bin/${encodeURIComponent(Script.version)}/${filename}`,
|
||||
sha256: sha256.digest("hex"),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
await rm(outdir, { recursive: true, force: true })
|
||||
if (dryRun) await mkdir(outdir, { recursive: true })
|
||||
if (!dryRun) {
|
||||
const token = process.env.GITHUB_TOKEN
|
||||
if (!token) throw new Error("GITHUB_TOKEN is required to update the Homebrew tap")
|
||||
await $`git clone ${`https://x-access-token:${token}@github.com/anomalyco/homebrew-tap.git`} ${outdir}`
|
||||
await $`git checkout -B master`.cwd(outdir)
|
||||
}
|
||||
|
||||
const target = (name: string) => {
|
||||
const result = targets.find((item) => item.name === name)
|
||||
if (!result) throw new Error(`Missing Homebrew target: ${name}`)
|
||||
return result
|
||||
}
|
||||
const macArm = target("darwin-arm64")
|
||||
const macIntel = target("darwin-x64-baseline")
|
||||
const linuxArm = target("linux-arm64")
|
||||
const linuxIntel = target("linux-x64-baseline")
|
||||
|
||||
await Bun.write(
|
||||
path.join(outdir, `${name}.rb`),
|
||||
[
|
||||
"# typed: false",
|
||||
"# frozen_string_literal: true",
|
||||
"",
|
||||
`class ${formulaClass} < Formula`,
|
||||
` desc "OpenCode V2${Script.channel === "beta" ? " beta" : ""} - the AI coding agent for the terminal"`,
|
||||
' homepage "https://github.com/anomalyco/opencode"',
|
||||
` version "${Script.version}"`,
|
||||
' license "MIT"',
|
||||
"",
|
||||
' depends_on "ripgrep"',
|
||||
' conflicts_with "opencode", because: "both install an opencode binary"',
|
||||
"",
|
||||
" on_macos do",
|
||||
" if Hardware::CPU.arm?",
|
||||
` url "${macArm.url}"`,
|
||||
` sha256 "${macArm.sha256}"`,
|
||||
" else",
|
||||
` url "${macIntel.url}"`,
|
||||
` sha256 "${macIntel.sha256}"`,
|
||||
" end",
|
||||
" end",
|
||||
"",
|
||||
" on_linux do",
|
||||
" if Hardware::CPU.arm?",
|
||||
` url "${linuxArm.url}"`,
|
||||
` sha256 "${linuxArm.sha256}"`,
|
||||
" else",
|
||||
` url "${linuxIntel.url}"`,
|
||||
` sha256 "${linuxIntel.sha256}"`,
|
||||
" end",
|
||||
" end",
|
||||
"",
|
||||
" def install",
|
||||
' bin.install "opencode"',
|
||||
" end",
|
||||
"end",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
console.log(`Prepared ${name} ${Script.version} in ${outdir}`)
|
||||
if (dryRun) process.exit(0)
|
||||
|
||||
await $`git add ${name + ".rb"}`.cwd(outdir)
|
||||
if ((await $`git diff --cached --quiet`.cwd(outdir).nothrow()).exitCode !== 0) {
|
||||
await $`git commit -m ${`chore: update ${name} to ${Script.version}`}`.cwd(outdir)
|
||||
await $`git push origin master`.cwd(outdir)
|
||||
}
|
||||
await UpdateArtifact.publish({
|
||||
channel: Script.channel,
|
||||
name: "cli",
|
||||
distribution: "homebrew",
|
||||
version: Script.version,
|
||||
metadata: { package: `anomalyco/tap/${name}` },
|
||||
})
|
||||
@@ -149,6 +149,10 @@ if (Script.channel === "latest" && Script.release && !dryRun) {
|
||||
|
||||
if ((Script.channel === "beta" || Script.channel === "latest") && Script.release) {
|
||||
await $`bun ./script/publish-aur.ts ${dryRun ? ["--dry-run"] : []}`.env({ ...process.env, OPENCODE_CLI_DIST: root })
|
||||
await $`bun ./script/publish-homebrew.ts ${dryRun ? ["--dry-run"] : []}`.env({
|
||||
...process.env,
|
||||
OPENCODE_CLI_DIST: root,
|
||||
})
|
||||
}
|
||||
|
||||
async function archive(bin: string, target: string, binary: string, directory: string) {
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { AppProcess } from "@opencode/util/process"
|
||||
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
|
||||
import { Context, Duration, Effect, FileSystem, Layer, Ref } from "effect"
|
||||
import { Context, Duration, Effect, FileSystem, Layer, Option, Ref, Schema } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { parse, type ParseError } from "jsonc-parser"
|
||||
import path from "node:path"
|
||||
import { action, parseReleaseVersion, type Policy } from "./updater-action"
|
||||
|
||||
export const methods = ["curl", "npm", "pnpm", "bun", "yarn"] as const
|
||||
export const methods = ["curl", "npm", "pnpm", "bun", "yarn", "vp", "brew"] as const
|
||||
|
||||
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 }
|
||||
|
||||
const decodeVpPackages = Schema.decodeUnknownOption(
|
||||
Schema.fromJsonString(Schema.Array(Schema.Struct({ name: Schema.String }))),
|
||||
)
|
||||
|
||||
export interface Interface {
|
||||
readonly run: (onInstall?: (version: string) => void) => Effect.Effect<RunResult | undefined>
|
||||
readonly check: () => Effect.Effect<CheckResult | undefined, Error>
|
||||
@@ -96,6 +101,13 @@ const make = Effect.gen(function* () {
|
||||
process.platform === "win32" ? "opencode.exe" : "opencode",
|
||||
)
|
||||
if (path.resolve(process.execPath) === path.resolve(binary)) return "curl"
|
||||
const executable = yield* fs.realPath(process.execPath).pipe(Effect.orElseSucceed(() => process.execPath))
|
||||
if (
|
||||
["opencode-beta", "opencode-v2"].some((name) =>
|
||||
executable.includes(`${path.sep}Cellar${path.sep}${name}${path.sep}`),
|
||||
)
|
||||
)
|
||||
return "brew"
|
||||
if (!installedPackage) return
|
||||
|
||||
const checks: ReadonlyArray<{ method: Method; command: string[] }> = [
|
||||
@@ -103,22 +115,30 @@ const make = Effect.gen(function* () {
|
||||
{ method: "pnpm", command: ["pnpm", "list", "-g", "--depth=0", installedPackage] },
|
||||
{ method: "bun", command: ["bun", "pm", "ls", "-g"] },
|
||||
{ method: "yarn", command: ["yarn", "global", "list"] },
|
||||
{ method: "vp", command: ["vp", "list", "-g", "--json", installedPackage] },
|
||||
]
|
||||
const results = yield* Effect.forEach(
|
||||
checks,
|
||||
(check) => exec(check.command).pipe(Effect.map((result) => ({ check, result }))),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
return results.find((result) => result.result.stdout.includes(installedPackage))?.check.method
|
||||
return results.find((result) => {
|
||||
if (result.check.method !== "vp") return result.result.stdout.includes(installedPackage)
|
||||
// Vite+ repeats the filter in its successful no-match message, so substring detection would be a false positive.
|
||||
return Option.exists(decodeVpPackages(result.result.stdout), (packages) =>
|
||||
packages.some((item) => item.name === installedPackage),
|
||||
)
|
||||
})?.check.method
|
||||
})
|
||||
|
||||
const removal = (method: Method) => {
|
||||
if (method === "curl" || !installedPackage) return undefined
|
||||
if (method === "curl" || method === "brew" || !installedPackage) return undefined
|
||||
const commands = {
|
||||
npm: ["npm", "uninstall", "--global", installedPackage],
|
||||
pnpm: ["pnpm", "remove", "--global", installedPackage],
|
||||
bun: ["bun", "remove", "--global", installedPackage],
|
||||
yarn: ["yarn", "global", "remove", installedPackage],
|
||||
vp: ["vp", "uninstall", "-g", installedPackage],
|
||||
}
|
||||
const command = commands[method]
|
||||
return {
|
||||
@@ -133,11 +153,12 @@ const make = Effect.gen(function* () {
|
||||
}
|
||||
}
|
||||
|
||||
const release = Effect.fnUntraced(function* () {
|
||||
const release = Effect.fnUntraced(function* (method?: Method) {
|
||||
const distribution = method === "brew" ? "homebrew" : "npm"
|
||||
const response = yield* Effect.tryPromise({
|
||||
try: (signal) =>
|
||||
fetch(
|
||||
`https://opencode.ai/update/api/${encodeURIComponent(channel)}/${encodeURIComponent(OPENCODE_ARTIFACT)}/npm?current=${encodeURIComponent(OPENCODE_VERSION)}`,
|
||||
`https://opencode.ai/update/api/${encodeURIComponent(channel)}/${encodeURIComponent(OPENCODE_ARTIFACT)}/${distribution}?current=${encodeURIComponent(OPENCODE_VERSION)}`,
|
||||
{
|
||||
signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]),
|
||||
},
|
||||
@@ -153,7 +174,11 @@ const make = Effect.gen(function* () {
|
||||
return { package: data.metadata.package, version: data.version }
|
||||
})
|
||||
|
||||
const latest = () => release().pipe(Effect.map((data) => data.version))
|
||||
const latest = () =>
|
||||
method().pipe(
|
||||
Effect.flatMap(release),
|
||||
Effect.map((data) => data.version),
|
||||
)
|
||||
|
||||
const temporaryDirectory = (prefix: string) =>
|
||||
Effect.acquireRelease(fs.makeTempDirectory({ directory: global.cache, prefix }), (directory) =>
|
||||
@@ -163,12 +188,12 @@ const make = Effect.gen(function* () {
|
||||
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/, "")
|
||||
const packageName = (yield* release()).package
|
||||
const packageName = (yield* release(method)).package
|
||||
const target = `${packageName}@${version}`
|
||||
if (installedPackage && packageName !== installedPackage && (method === "pnpm" || method === "yarn")) {
|
||||
return yield* Effect.fail(new Error(`Reinstall ${target} with ${method} to migrate from ${installedPackage}.`))
|
||||
}
|
||||
const commands: Record<Exclude<Method, "bun" | "curl">, string[]> = {
|
||||
const commands: Record<Exclude<Method, "bun" | "curl" | "brew">, string[]> = {
|
||||
// Keep the old package: uninstalling it can unlink the replacement command.
|
||||
npm: [
|
||||
"npm",
|
||||
@@ -182,6 +207,10 @@ const make = Effect.gen(function* () {
|
||||
],
|
||||
pnpm: ["pnpm", "add", "--global", `--allow-build=${packageName}`, target],
|
||||
yarn: ["yarn", "global", "add", target],
|
||||
vp:
|
||||
installedPackage && packageName !== installedPackage
|
||||
? ["vp", "install", "-g", "--force", target]
|
||||
: ["vp", "update", "-g", target],
|
||||
}
|
||||
const result = yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
@@ -202,6 +231,7 @@ const make = Effect.gen(function* () {
|
||||
if (download.code !== 0) return download
|
||||
return yield* exec(["bash", installer, "--version", version, "--no-modify-path"], "5 minutes")
|
||||
}
|
||||
if (method === "brew") return yield* exec(["brew", "upgrade", packageName], "5 minutes")
|
||||
return yield* exec(commands[method], "5 minutes")
|
||||
}),
|
||||
).pipe(Effect.mapError((cause) => new Error(`Failed to update with ${method}`, { cause })))
|
||||
|
||||
@@ -19,6 +19,7 @@ function fixture(
|
||||
} = () => ({}),
|
||||
name = "@opencode/cli",
|
||||
failCleanup = false,
|
||||
releasePackage = name,
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
@@ -34,7 +35,7 @@ function fixture(
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
spyOn(globalThis, "fetch").mockImplementation(
|
||||
Object.assign(async () => Response.json({ version: "2.3.4", metadata: { package: name } }), {
|
||||
Object.assign(async () => Response.json({ version: "2.3.4", metadata: { package: releasePackage } }), {
|
||||
preconnect: fetch.preconnect,
|
||||
}),
|
||||
),
|
||||
@@ -106,6 +107,7 @@ const installs = [
|
||||
command: ["pnpm", "add", "--global", "--allow-build=@opencode/cli", "@opencode/cli@2.3.4-beta.1"],
|
||||
},
|
||||
{ method: "yarn", command: ["yarn", "global", "add", "@opencode/cli@2.3.4-beta.1"] },
|
||||
{ method: "vp", command: ["vp", "update", "-g", "@opencode/cli@2.3.4-beta.1"] },
|
||||
] as const
|
||||
|
||||
installs.forEach(({ method, command }) => {
|
||||
@@ -117,6 +119,25 @@ installs.forEach(({ method, command }) => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.live("vp force-installs a renamed V2 package that replaces the existing binary owner", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* fixture(() => ({}), "@opencode/cli", false, "@opencode/cli-node")
|
||||
yield* test.updater.upgrade("vp", "v2.3.4-beta.1")
|
||||
expect(test.commands).toEqual([["vp", "install", "-g", "--force", "@opencode/cli-node@2.3.4-beta.1"]])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("vp removes the package from its managed global store", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* fixture()
|
||||
const removal = test.updater.removal("vp")
|
||||
if (!removal) return yield* Effect.die("Expected vp removal command")
|
||||
expect(removal.command).toEqual(["vp", "uninstall", "-g", "@opencode/cli"])
|
||||
yield* removal.run
|
||||
expect(test.commands).toEqual([["vp", "uninstall", "-g", "@opencode/cli"]])
|
||||
}),
|
||||
)
|
||||
;[0, 1].forEach((exitCode) => {
|
||||
it.live(`bun isolates and removes its install cache after exit ${exitCode}`, () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -200,11 +221,19 @@ it.live("install failures expose stderr and process errors do not report success
|
||||
expect(missing.commands).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
;(["npm", "pnpm", "bun", "yarn", undefined] as const).forEach((method) => {
|
||||
;(["npm", "pnpm", "bun", "yarn", "vp", undefined] as const).forEach((method) => {
|
||||
it.live(`method detection identifies ${method ?? "an unknown installation"} using the V2 package`, () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* fixture((command) => ({
|
||||
stdout: Buffer.from(command.command === method ? "@opencode/cli@2.3.4" : "opencode-ai@1.0.0"),
|
||||
stdout: Buffer.from(
|
||||
command.command === method
|
||||
? method === "vp"
|
||||
? JSON.stringify([{ name: "@opencode/cli", version: "2.3.4" }])
|
||||
: "@opencode/cli@2.3.4"
|
||||
: command.command === "vp"
|
||||
? "[]"
|
||||
: "opencode-ai@1.0.0",
|
||||
),
|
||||
}))
|
||||
expect(yield* test.updater.method()).toBe(method)
|
||||
expect(test.commands).toEqual([
|
||||
@@ -212,6 +241,7 @@ it.live("install failures expose stderr and process errors do not report success
|
||||
["pnpm", "list", "-g", "--depth=0", "@opencode/cli"],
|
||||
["bun", "pm", "ls", "-g"],
|
||||
["yarn", "global", "list"],
|
||||
["vp", "list", "-g", "--json", "@opencode/cli"],
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -225,7 +255,16 @@ it.live("method detection tolerates unavailable package managers", () =>
|
||||
: { error: new AppProcess.AppProcessError({ command: command.command }) },
|
||||
)
|
||||
expect(yield* test.updater.method()).toBe("yarn")
|
||||
expect(test.commands).toHaveLength(4)
|
||||
expect(test.commands).toHaveLength(5)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("vp detection ignores no-match output that repeats the package name", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* fixture((command) => ({
|
||||
stdout: Buffer.from(command.command === "vp" ? "No global packages matching '@opencode/cli'." : ""),
|
||||
}))
|
||||
expect(yield* test.updater.method()).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -265,13 +304,16 @@ if (typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-no
|
||||
expect(yield* test.updater.method()).toBe("npm")
|
||||
yield* test.updater.upgrade("npm", "v2.3.4")
|
||||
yield* test.updater.upgrade("pnpm", "v2.3.4")
|
||||
yield* test.updater.upgrade("vp", "v2.3.4")
|
||||
expect(test.commands).toEqual([
|
||||
["npm", "list", "-g", "--depth=0", "@opencode/cli-node"],
|
||||
["pnpm", "list", "-g", "--depth=0", "@opencode/cli-node"],
|
||||
["bun", "pm", "ls", "-g"],
|
||||
["yarn", "global", "list"],
|
||||
["vp", "list", "-g", "--json", "@opencode/cli-node"],
|
||||
["npm", "install", "--global", "@opencode/cli-node@2.3.4"],
|
||||
["pnpm", "add", "--global", "--allow-build=@opencode/cli-node", "@opencode/cli-node@2.3.4"],
|
||||
["vp", "update", "-g", "@opencode/cli-node@2.3.4"],
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -35,6 +35,12 @@ describe("upgrade command", () => {
|
||||
expect(result.events).toEqual([{ method: "bun", version: "2.0.0" }])
|
||||
})
|
||||
|
||||
test("accepts vp as an explicit installation method", async () => {
|
||||
const result = await cli(["2.0.0", "--method", "vp"])
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.events).toEqual([{ method: "vp", version: "2.0.0" }])
|
||||
})
|
||||
|
||||
test("skips the already installed version", async () => {
|
||||
const result = await cli(["v0.0.0-beta-old"])
|
||||
expect(result.exitCode).toBe(0)
|
||||
@@ -50,7 +56,7 @@ describe("upgrade command", () => {
|
||||
})
|
||||
|
||||
test("rejects unsupported methods before attempting an upgrade", async () => {
|
||||
const result = await cli(["--method", "brew"])
|
||||
const result = await cli(["--method", "apt"])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.events).toEqual([])
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -13,8 +13,8 @@ The idea of code mode was originally introduced by Cloudflare. See
|
||||
|
||||
## How it differs from JavaScript
|
||||
|
||||
- **Only supported APIs are available.** Programs can use the provided tools and supported JavaScript built-ins. APIs
|
||||
such as `fetch`, timers, `process`, filesystem access, imports, and modules are unavailable.
|
||||
- **Only supported APIs are available.** Programs can use the provided tools, supported JavaScript built-ins, and the
|
||||
globals the host adds through extensions. Timers, `process`, filesystem access, imports, and modules are unavailable.
|
||||
- **Unfinished work is interrupted.** Tool calls and async functions start when called. When the program finishes,
|
||||
anything still running is interrupted. Unhandled rejections from un-awaited promises are returned as warnings.
|
||||
- **REPL-style results.** Without an explicit `return`, the final top-level expression becomes the result. `undefined`
|
||||
@@ -94,11 +94,19 @@ receive `{ extension, name, args }`. An `after` hook also receives how the call
|
||||
`failure` with its error, or `interrupted`). A failing `before` hook denies the call, and the program catches the
|
||||
failure as a thrown error.
|
||||
|
||||
### `Values`
|
||||
### `Extension.make`
|
||||
|
||||
`Values` exports the runtime's non-JSON value classes: `Values.URL`, `Values.URLSearchParams`, `Values.Date`,
|
||||
`Values.RegExp`, `Values.Map`, `Values.Set`, and `Values.Promise`. The interpreter recognizes these by class; a
|
||||
program's `new URL(...)` is a `Values.URL` wrapping the host `URL`. `Values.isValue` narrows to the data-like kinds.
|
||||
Extensions are host functions a program calls directly as globals, such as `fetch`. Unlike tools they are not in the
|
||||
catalog, not counted against `maxToolCalls`, and not described to the model; the host decides what they mean.
|
||||
|
||||
```ts
|
||||
const web = Extension.make({ name: "web", globals: { fetch: (url: string) => globalThis.fetch(url) } })
|
||||
const runtime = CodeMode.make({ tools, extensions: [web] })
|
||||
```
|
||||
|
||||
Every value crossing in either direction is converted, never shared: arguments come in as copies, results go out as
|
||||
copies, and a function inside a result is callable the same way. A global that shadows a built-in or another
|
||||
extension throws at `CodeMode.make`.
|
||||
|
||||
### OpenAPI tools
|
||||
|
||||
|
||||
@@ -15,8 +15,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
## Source and execution model
|
||||
|
||||
- [x] JavaScript parsed with the latest syntax accepted by Acorn, then restricted by the interpreter allowlist.
|
||||
- [x] Erasable TypeScript syntax, including type annotations, type declarations, assertions, and non-null assertions.
|
||||
TypeScript is transpiled first; the emitted JavaScript must still use the supported subset.
|
||||
TypeScript-only syntax is rejected rather than stripped before execution.
|
||||
- [x] Top-level `await` and `return` through the program's implicit async-function scope.
|
||||
- [x] Explicit `return`, final top-level expression as a REPL-style result, and `null` when no value is produced.
|
||||
- [x] The host boundary is `JSON.stringify` plus a short table. The program result and tool arguments cross as
|
||||
@@ -29,7 +28,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
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 `{}`.
|
||||
- [x] Live Date, RegExp, Map, Set, URL, URLSearchParams, and Uint8Array values inside CodeMode.
|
||||
- [x] Live Date, RegExp, Map, Set, URL, URLSearchParams, Headers, and Uint8Array values inside CodeMode.
|
||||
- [x] Tool calls through the host-provided `tools` tree only.
|
||||
- [x] The global `search(...)` built-in: synchronous tool discovery that counts as an admitted tool call and is
|
||||
shadowable by program declarations like other globals.
|
||||
@@ -41,14 +40,12 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
expression match can still run long on a pathological pattern; the host regex engine has no interrupt hook.
|
||||
- [ ] 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"`.
|
||||
- [ ] Valid JavaScript rejected by TypeScript transpilation before interpretation, such as `in` inside a destructuring
|
||||
default in a `for...of` head and Unicode-escaped keywords.
|
||||
|
||||
## Values and literals
|
||||
|
||||
- [x] `null`, `undefined`, booleans, finite and non-finite numbers, and strings.
|
||||
- [x] Array literals, including holes and spread from arrays, strings, Maps, Sets, URLSearchParams, custom synchronous
|
||||
iterators, and synchronous generators.
|
||||
- [x] Array literals, including holes and spread from arrays, strings, Maps, Sets, URLSearchParams, Headers, custom
|
||||
synchronous iterators, and synchronous generators.
|
||||
- [x] Object literals with shorthand, computed string/number keys, and spread following ToObject: data objects and
|
||||
arrays copy own enumerable keys, strings copy index keys, and other values contribute nothing.
|
||||
- [x] Template literals with interpolation.
|
||||
@@ -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 custom iterator protocols.
|
||||
`Symbol.asyncIterator` keys are available only for the iterator protocols.
|
||||
- [ ] Tagged-template calls.
|
||||
- [ ] Getter and setter definitions in object literals.
|
||||
|
||||
@@ -95,8 +92,8 @@ 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, custom synchronous iterators, and confined
|
||||
synchronous generators. Abrupt completion invokes the iterator's optional `return()`.
|
||||
- [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; other values iterate nothing.
|
||||
- [x] Unlabeled `break` and `continue`.
|
||||
- [x] `try`, `catch`, optional catch bindings, and `finally`.
|
||||
@@ -127,7 +124,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
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`, `Promise`) throw a `TypeError`,
|
||||
and new-requiring constructors (`Map`, `Set`, `URL`, `URLSearchParams`, `Headers`, `Promise`) throw a `TypeError`,
|
||||
like JS.
|
||||
- [x] Tool references and detached `Promise` statics are rejected as callbacks with a hint to wrap them in an
|
||||
arrow function.
|
||||
@@ -179,10 +176,10 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] Sequence expressions (the comma operator).
|
||||
- [x] `await` for CodeMode promises and callable thenables; a plain value passes through unchanged, though every
|
||||
`await` still defers its continuation one reaction turn.
|
||||
- [x] `new` for Array, Object, Error types, Date, RegExp, Map, Set, URL, URLSearchParams, and Promise. `new` 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
|
||||
- [x] `new` for Array, Object, Error types, Date, RegExp, Map, Set, URL, URLSearchParams, Headers, and Promise. `new`
|
||||
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.
|
||||
- [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`.
|
||||
- [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`.
|
||||
@@ -290,7 +287,10 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] Ordering: `sort`, `toSorted`, `reverse`, and `toReversed`.
|
||||
- [x] Access/copying: `at`, `slice`, `concat`, `flat`, `with`, and `join`.
|
||||
- [x] Mutation: `push`, `pop`, `shift`, `unshift`, `splice`, `fill`, and `copyWithin`.
|
||||
- [x] Materialized iteration helpers: `keys`, `values`, and `entries` return arrays rather than iterators.
|
||||
- [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, which is only observable through `getPrototypeOf`.
|
||||
- [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`.
|
||||
@@ -305,7 +305,6 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
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
|
||||
|
||||
@@ -317,7 +316,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [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, and `for...of` by Unicode code point.
|
||||
- [x] `toString`, `length`, numeric indexing, spread, `for...of`, and `[Symbol.iterator]` 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
|
||||
@@ -408,7 +407,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [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] Materialized `keys`, `values`, and `entries` arrays for Map and Set.
|
||||
- [x] Live `keys`, `values`, `entries`, and `[Symbol.iterator]` iterators for Map and Set; a Set-like operand's `keys()`
|
||||
may return a built-in iterator or an array.
|
||||
- [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`,
|
||||
@@ -424,7 +424,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [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`, `toString`, and `size`.
|
||||
`entries`, `[Symbol.iterator]`, `toString`, and `size`.
|
||||
- [x] URL values serialize to their href; URLSearchParams serialize to `{}`.
|
||||
|
||||
## Uint8Array
|
||||
@@ -437,7 +437,8 @@ 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 materialized `keys`, `values`, and `entries` arrays.
|
||||
`includes`, `join`, `toString`, `toBase64`, `toHex`, and live `keys`, `values`, `entries`, and `[Symbol.iterator]`
|
||||
iterators.
|
||||
- [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) [...]`.
|
||||
@@ -451,7 +452,13 @@ with a hint to encode as text first (`TextDecoder`, `toBase64`, `toHex`).
|
||||
- [x] `crypto.randomUUID()` and `crypto.getRandomValues(uint8Array)`.
|
||||
- [x] `TextEncoder` and `TextDecoder` for UTF-8 only: any other label is a `RangeError`. `TextDecoder` accepts the
|
||||
`fatal` and `ignoreBOM` options; `decode` takes a Uint8Array or nothing.
|
||||
- [ ] `crypto.subtle`, `Blob`, and `TextDecoder` streaming or non-UTF-8 encodings.
|
||||
- [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 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.
|
||||
|
||||
## Extensions
|
||||
|
||||
@@ -461,8 +468,8 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
|
||||
- [x] Each global is a function, callable but not constructible, run with `this` undefined. A global that shadows
|
||||
a built-in or another extension throws at `make`.
|
||||
- [x] Every value crossing in either direction is converted, never shared: plain objects and arrays are copied,
|
||||
`Date`, `RegExp`, `URL`, `URLSearchParams`, `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),
|
||||
`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 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
|
||||
@@ -494,9 +501,8 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
|
||||
- [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
|
||||
`catch`.
|
||||
- [x] Source locations on unsupported-syntax diagnostics for JavaScript-shaped input; TypeScript transpilation may
|
||||
shift them. The diagnostic names the rejected node type and attaches a short orientation to the supported
|
||||
subset; this matrix is the full reference.
|
||||
- [x] Source locations on unsupported-syntax diagnostics. The diagnostic names the rejected node type and attaches a
|
||||
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 with `name` and `message` in `catch`, rejection handlers, and `Promise.allSettled` reasons.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"description": "Effect-native confined code execution over schema-described tools",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -19,12 +19,6 @@
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"imports": {
|
||||
"#transpile": {
|
||||
"workerd": "./src/interpreter/transpile.workerd.ts",
|
||||
"default": "./src/interpreter/transpile.node.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bun run script/build.ts",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
@@ -32,8 +26,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
"effect": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
|
||||
@@ -15,7 +15,7 @@ const pkg = JSON.parse(originalText) as {
|
||||
name: string
|
||||
version: string
|
||||
exports: Record<string, string | { import: string; types: string }>
|
||||
imports: Record<string, Record<string, string>>
|
||||
imports?: Record<string, Record<string, string>>
|
||||
}
|
||||
const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`
|
||||
const output = (value: string, types = false) =>
|
||||
@@ -41,12 +41,14 @@ try {
|
||||
]
|
||||
}),
|
||||
)
|
||||
pkg.imports = Object.fromEntries(
|
||||
Object.entries(pkg.imports).map(([key, conditions]) => [
|
||||
key,
|
||||
Object.fromEntries(Object.entries(conditions).map(([condition, value]) => [condition, output(value)])),
|
||||
]),
|
||||
)
|
||||
if (pkg.imports) {
|
||||
pkg.imports = Object.fromEntries(
|
||||
Object.entries(pkg.imports).map(([key, conditions]) => [
|
||||
key,
|
||||
Object.fromEntries(Object.entries(conditions).map(([condition, value]) => [condition, output(value)])),
|
||||
]),
|
||||
)
|
||||
}
|
||||
await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n")
|
||||
await rm(tarball, { force: true })
|
||||
await $`bun pm pack`
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
record,
|
||||
SetObj,
|
||||
URLSearchParamsObj,
|
||||
HeadersObj,
|
||||
} from "./interpreter/objects.js"
|
||||
import { typeofValue } from "./interpreter/references.js"
|
||||
|
||||
@@ -69,6 +70,7 @@ const walk = <R>(
|
||||
)
|
||||
}
|
||||
if (boundary && value instanceof URLSearchParamsObj) return value.params.toString()
|
||||
if (value instanceof HeadersObj) return Object.fromEntries(value.headers)
|
||||
const target = boundary && value instanceof SetObj ? new Arr(ctx.builtins.Array, [...value.set]) : value
|
||||
if (stack.has(target)) throw typeError("Converting circular structure to JSON.")
|
||||
stack.add(target)
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { parse, type Program } from "acorn"
|
||||
import { Cause, Effect, Scope } from "effect"
|
||||
// #transpile: conditional import — full typescript on node/bun, an identity
|
||||
// pass-through on workerd (the compiler is ~11 MiB and can't init there).
|
||||
import { transpile } from "#transpile"
|
||||
import type { DataValue, Diagnostic, ResolvedExecutionLimits, Result } from "../codemode.js"
|
||||
import { toBoundary } from "../data.js"
|
||||
import { ToolRuntime } from "../tool-runtime.js"
|
||||
import { normalizeError } from "./errors.js"
|
||||
import { createBuiltins } from "./intrinsics.js"
|
||||
import { PendingThrow } from "./model.js"
|
||||
import { Pending } from "./promises.js"
|
||||
import { Interpreter } from "./interpreter.js"
|
||||
|
||||
@@ -110,16 +106,7 @@ export const executeProgram = <R>(
|
||||
}
|
||||
|
||||
const parseProgram = (code: string): Program => {
|
||||
const transpiled = transpile(`async function __codemode__() {\n${code}\n}`)
|
||||
|
||||
if (transpiled.error !== undefined) {
|
||||
throw new PendingThrow("SyntaxError", `Failed to parse TypeScript: ${transpiled.error}`, undefined, "ParseError")
|
||||
}
|
||||
|
||||
const bodyStart = transpiled.outputText.indexOf("{") + 1
|
||||
const bodyEnd = transpiled.outputText.lastIndexOf("}")
|
||||
const executableCode = transpiled.outputText.slice(bodyStart, bodyEnd)
|
||||
return parse(executableCode, {
|
||||
return parse(code, {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "script",
|
||||
allowReturnOutsideFunction: true,
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
DateObj,
|
||||
ErrorObj,
|
||||
GeneratorObj,
|
||||
IteratorObj,
|
||||
MapObj,
|
||||
Obj,
|
||||
PromiseObj,
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
SetObj,
|
||||
URLObj,
|
||||
URLSearchParamsObj,
|
||||
HeadersObj,
|
||||
} from "./objects.js"
|
||||
import { describeValue } from "./references.js"
|
||||
|
||||
@@ -52,6 +54,7 @@ export const extensionGlobals = <R>(
|
||||
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))
|
||||
@@ -59,6 +62,7 @@ export const extensionGlobals = <R>(
|
||||
!(value instanceof Obj) ||
|
||||
value instanceof Callable ||
|
||||
value instanceof GeneratorObj ||
|
||||
value instanceof IteratorObj ||
|
||||
value instanceof PromiseObj
|
||||
) {
|
||||
throw typeError(`${label} contains ${describeValue(value)}, which cannot be passed to an extension.`)
|
||||
@@ -125,6 +129,7 @@ export const extensionGlobals = <R>(
|
||||
if (value instanceof URLSearchParams) {
|
||||
return new URLSearchParamsObj(builtins.URLSearchParams, new URLSearchParams(value))
|
||||
}
|
||||
if (value instanceof Headers) return new HeadersObj(builtins.Headers, new Headers(value))
|
||||
if (value instanceof Map) {
|
||||
const wrapped = new MapObj(builtins.Map)
|
||||
for (const [key, item] of value) wrapped.map.set(next(key, label), next(item, label))
|
||||
|
||||
@@ -11,6 +11,8 @@ import { objectGlobal } from "../stdlib/object.js"
|
||||
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"
|
||||
@@ -80,6 +82,7 @@ const table: Record<string, Factory> = {
|
||||
Set: (ctx) => setGlobal(ctx),
|
||||
URL: (ctx) => urlGlobal(ctx),
|
||||
URLSearchParams: (ctx) => urlSearchParamsGlobal(ctx),
|
||||
Headers: (ctx) => headersGlobal(ctx),
|
||||
Uint8Array: (ctx) => uint8ArrayGlobal(ctx),
|
||||
TextEncoder: (ctx) => textEncoderGlobal(ctx),
|
||||
TextDecoder: (ctx) => textDecoderGlobal(ctx),
|
||||
@@ -99,5 +102,6 @@ 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, unknown]> => {
|
||||
generatorGlobals(ctx)
|
||||
iteratorGlobals(ctx)
|
||||
return Object.entries(table).map(([name, factory]) => [name, factory(ctx)] as const)
|
||||
}
|
||||
|
||||
@@ -79,11 +79,13 @@ import {
|
||||
DateObj,
|
||||
Fn,
|
||||
GeneratorObj,
|
||||
IteratorObj,
|
||||
MapObj,
|
||||
Obj,
|
||||
PromiseObj,
|
||||
SetObj,
|
||||
URLSearchParamsObj,
|
||||
HeadersObj,
|
||||
record,
|
||||
remove,
|
||||
set,
|
||||
@@ -649,11 +651,11 @@ class Frame<R> {
|
||||
if (declared?.lexical) self.predeclarePattern(declared.pattern, declared.mutable, left)
|
||||
const right = yield* self.evaluateExpression(node.right)
|
||||
|
||||
const iterator = yield* self.customIterator(right, node, awaiting)
|
||||
const cursor = iterator === undefined ? yield* self.iterate(right, node) : undefined
|
||||
const cursor = self.hostCursor(right)
|
||||
const iterator = cursor === undefined ? yield* self.customIterator(right, node, awaiting) : undefined
|
||||
if (iterator === undefined && cursor === undefined) {
|
||||
throw invalidData(
|
||||
`${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, or URLSearchParams, or custom iterator value.`,
|
||||
`${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, URLSearchParams, or Headers, or custom iterator value.`,
|
||||
node,
|
||||
)
|
||||
}
|
||||
@@ -745,6 +747,20 @@ class Frame<R> {
|
||||
}
|
||||
|
||||
iterate(value: unknown, node?: AstNode) {
|
||||
const cursor = this.hostCursor(value)
|
||||
if (cursor !== undefined) return Effect.succeed(cursor)
|
||||
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 hostCursor(value: unknown) {
|
||||
const iterator =
|
||||
value instanceof Arr
|
||||
? value.items[Symbol.iterator]()
|
||||
@@ -756,31 +772,25 @@ class Frame<R> {
|
||||
? value.set.values()
|
||||
: value instanceof URLSearchParamsObj
|
||||
? value.params.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,
|
||||
})
|
||||
: value instanceof HeadersObj
|
||||
? value.headers.entries()
|
||||
: value instanceof Bytes
|
||||
? value.bytes.values()
|
||||
: value instanceof IteratorObj
|
||||
? value.iterator
|
||||
: undefined
|
||||
if (iterator === undefined) return undefined
|
||||
const proto = this.ctx.builtins.Array
|
||||
return {
|
||||
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: unknown, node: AstNode | undefined, allowAsync = true) {
|
||||
@@ -1848,6 +1858,7 @@ class Frame<R> {
|
||||
value instanceof MapObj ||
|
||||
value instanceof SetObj ||
|
||||
value instanceof URLSearchParamsObj ||
|
||||
value instanceof HeadersObj ||
|
||||
value instanceof Bytes
|
||||
) {
|
||||
const cursor = yield* self.iterate(value, node)
|
||||
|
||||
@@ -29,6 +29,7 @@ const builtins = [
|
||||
"Set",
|
||||
"URL",
|
||||
"URLSearchParams",
|
||||
"Headers",
|
||||
"Uint8Array",
|
||||
"TextEncoder",
|
||||
"TextDecoder",
|
||||
@@ -80,6 +81,7 @@ export const createBuiltins = (): Builtins => {
|
||||
Set: plain(),
|
||||
URL: plain(),
|
||||
URLSearchParams: plain(),
|
||||
Headers: plain(),
|
||||
Uint8Array: plain(),
|
||||
TextEncoder: plain(),
|
||||
TextDecoder: plain(),
|
||||
|
||||
@@ -122,6 +122,16 @@ export class GeneratorObj extends Obj {
|
||||
}
|
||||
}
|
||||
|
||||
/** A built-in collection iterator: live over the host collection, yielding program values. */
|
||||
export class IteratorObj extends Obj {
|
||||
constructor(
|
||||
proto: Obj,
|
||||
readonly iterator: IteratorObject<unknown>,
|
||||
) {
|
||||
super(proto)
|
||||
}
|
||||
}
|
||||
|
||||
export class DateObj extends Obj {
|
||||
constructor(
|
||||
proto: Obj,
|
||||
@@ -156,6 +166,15 @@ export class URLSearchParamsObj extends Obj {
|
||||
}
|
||||
}
|
||||
|
||||
export class HeadersObj extends Obj {
|
||||
constructor(
|
||||
proto: Obj,
|
||||
readonly headers: Headers,
|
||||
) {
|
||||
super(proto)
|
||||
}
|
||||
}
|
||||
|
||||
export class URLObj extends Obj {
|
||||
readonly searchParams: URLSearchParamsObj
|
||||
constructor(
|
||||
@@ -181,13 +200,14 @@ export class Bytes extends Obj {
|
||||
/** 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 | Bytes =>
|
||||
): 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
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Bytes,
|
||||
DateObj,
|
||||
GeneratorObj,
|
||||
IteratorObj,
|
||||
MapObj,
|
||||
Obj,
|
||||
PromiseObj,
|
||||
@@ -16,12 +17,14 @@ import {
|
||||
SetObj,
|
||||
URLObj,
|
||||
URLSearchParamsObj,
|
||||
HeadersObj,
|
||||
} from "./objects.js"
|
||||
|
||||
/** Values that cannot cross the data boundary. */
|
||||
export const isRuntimeReference = (value: unknown): boolean =>
|
||||
value instanceof Callable ||
|
||||
value instanceof GeneratorObj ||
|
||||
value instanceof IteratorObj ||
|
||||
value instanceof ToolReference ||
|
||||
value instanceof PromiseObj ||
|
||||
isWrapper(value)
|
||||
@@ -85,8 +88,10 @@ export const describeValue = (value: unknown): string => {
|
||||
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 (value instanceof IteratorObj) return "an iterator"
|
||||
if (isRuntimeReference(value)) return "a function"
|
||||
if (typeof value === "object") return "a data object"
|
||||
return `a ${typeof value}`
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript"
|
||||
|
||||
export interface TranspileResult {
|
||||
readonly outputText: string
|
||||
readonly error?: string
|
||||
}
|
||||
|
||||
// Full TypeScript transpilation on node/bun runtimes.
|
||||
export const transpile = (source: string): TranspileResult => {
|
||||
const transpiled = transpileModule(source, {
|
||||
reportDiagnostics: true,
|
||||
compilerOptions: {
|
||||
target: ScriptTarget.ESNext,
|
||||
module: ModuleKind.ESNext,
|
||||
},
|
||||
})
|
||||
const diagnostic = transpiled.diagnostics?.find((item) => item.category === DiagnosticCategory.Error)
|
||||
if (diagnostic) {
|
||||
return {
|
||||
outputText: transpiled.outputText,
|
||||
error: flattenDiagnosticMessageText(diagnostic.messageText, "\n"),
|
||||
}
|
||||
}
|
||||
return { outputText: transpiled.outputText }
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
export interface TranspileResult {
|
||||
readonly outputText: string
|
||||
readonly error?: string
|
||||
}
|
||||
|
||||
// workerd profile: the typescript compiler is ~11 MiB and probes node
|
||||
// internals at module init, so codemode programs are passed through
|
||||
// untranspiled. Plain-JS programs (the overwhelmingly common case) parse
|
||||
// fine downstream via acorn; TypeScript-only syntax surfaces as a parse
|
||||
// error from the interpreter instead of a transpile diagnostic.
|
||||
export const transpile = (source: string): TranspileResult => ({ outputText: source })
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Effect } from "effect"
|
||||
import { constructor, type Method, methods, prototypeFrom, receiver } from "../interpreter/native.js"
|
||||
import { checkArrayLength, checkStringLength, MAX_ARRAY_LENGTH } from "../interpreter/limits.js"
|
||||
import { invalidData, rangeError, typeError } from "../interpreter/model.js"
|
||||
import { get, Arr, GeneratorObj, Obj } from "../interpreter/objects.js"
|
||||
import { invalidData, IteratorSymbol, rangeError, typeError } from "../interpreter/model.js"
|
||||
import { define, get, hidden, Arr, GeneratorObj, IteratorObj, Obj } from "../interpreter/objects.js"
|
||||
import { describeValue, rejectCircularInsertion } from "../interpreter/references.js"
|
||||
import { applyCollectionCallback, preserveConsumerError } from "../interpreter/callback.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
@@ -340,13 +340,18 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
return target
|
||||
},
|
||||
],
|
||||
["keys", 0, (thisValue) => wrap(Array.from(self(thisValue, "keys").items.keys()))],
|
||||
["values", 0, (thisValue) => wrap([...self(thisValue, "values").items])],
|
||||
["keys", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "keys").items.keys())],
|
||||
["values", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "values").items.values())],
|
||||
[
|
||||
"entries",
|
||||
0,
|
||||
(thisValue) =>
|
||||
wrap(Array.from(self(thisValue, "entries").items.entries(), ([index, item]) => wrap([index, item]))),
|
||||
new IteratorObj(
|
||||
builtins.Iterator,
|
||||
self(thisValue, "entries")
|
||||
.items.entries()
|
||||
.map(([index, item]) => wrap([index, item])),
|
||||
),
|
||||
],
|
||||
iterate("map", 1, (target, receiver, apply) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -490,5 +495,6 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
}),
|
||||
),
|
||||
])
|
||||
define(proto, IteratorSymbol, get(proto, "values"), hidden)
|
||||
return array
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Effect } from "effect"
|
||||
import { checkArrayLength, checkStringLength } from "../interpreter/limits.js"
|
||||
import { constructor, methods, prototypeFrom, receiver, requiresNew } from "../interpreter/native.js"
|
||||
import { rangeError, syntaxError, typeError } from "../interpreter/model.js"
|
||||
import { defineAccessor, get, Arr, Bytes, Obj } from "../interpreter/objects.js"
|
||||
import { IteratorSymbol, rangeError, syntaxError, typeError } from "../interpreter/model.js"
|
||||
import { define, defineAccessor, get, hidden, Arr, Bytes, IteratorObj, Obj } from "../interpreter/objects.js"
|
||||
import { describeValue } from "../interpreter/references.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
import { coerceToNumber, coerceToString } from "./value.js"
|
||||
@@ -173,15 +173,21 @@ export const uint8ArrayGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
["toString", 0, (thisValue) => self(thisValue, "toString").bytes.join(",")],
|
||||
["toBase64", 0, (thisValue) => self(thisValue, "toBase64").bytes.toBase64()],
|
||||
["toHex", 0, (thisValue) => self(thisValue, "toHex").bytes.toHex()],
|
||||
["keys", 0, (thisValue) => wrapAll(Array.from(self(thisValue, "keys").bytes.keys()))],
|
||||
["values", 0, (thisValue) => wrapAll(Array.from(self(thisValue, "values").bytes.values()))],
|
||||
["keys", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "keys").bytes.keys())],
|
||||
["values", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "values").bytes.values())],
|
||||
[
|
||||
"entries",
|
||||
0,
|
||||
(thisValue) =>
|
||||
wrapAll(Array.from(self(thisValue, "entries").bytes.entries(), ([index, byte]) => wrapAll([index, byte]))),
|
||||
new IteratorObj(
|
||||
builtins.Iterator,
|
||||
self(thisValue, "entries")
|
||||
.bytes.entries()
|
||||
.map(([index, byte]) => wrapAll([index, byte])),
|
||||
),
|
||||
],
|
||||
])
|
||||
define(proto, IteratorSymbol, get(proto, "values"), hidden)
|
||||
return uint8Array
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Effect } from "effect"
|
||||
import { constructor, fn, type Method, methods, prototypeFrom, receiver, requiresNew } from "../interpreter/native.js"
|
||||
import { invalidData, typeError } from "../interpreter/model.js"
|
||||
import { invalidData, IteratorSymbol, typeError } from "../interpreter/model.js"
|
||||
import {
|
||||
define,
|
||||
defineAccessor,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
hidden,
|
||||
isWrapper,
|
||||
Arr,
|
||||
IteratorObj,
|
||||
MapObj,
|
||||
Obj,
|
||||
PromiseObj,
|
||||
@@ -153,12 +154,18 @@ export const mapGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
return undefined
|
||||
},
|
||||
],
|
||||
["keys", 0, (thisValue) => wrap(Array.from(self(thisValue, "keys").map.keys()))],
|
||||
["values", 0, (thisValue) => wrap(Array.from(self(thisValue, "values").map.values()))],
|
||||
["keys", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "keys").map.keys())],
|
||||
["values", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "values").map.values())],
|
||||
[
|
||||
"entries",
|
||||
0,
|
||||
(thisValue) => wrap(Array.from(self(thisValue, "entries").map.entries(), ([key, item]) => wrap([key, item]))),
|
||||
(thisValue) =>
|
||||
new IteratorObj(
|
||||
builtins.Iterator,
|
||||
self(thisValue, "entries")
|
||||
.map.entries()
|
||||
.map(([key, item]) => wrap([key, item])),
|
||||
),
|
||||
],
|
||||
[
|
||||
"forEach",
|
||||
@@ -173,6 +180,7 @@ export const mapGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
},
|
||||
],
|
||||
])
|
||||
define(proto, IteratorSymbol, get(proto, "entries"), hidden)
|
||||
return map
|
||||
}
|
||||
|
||||
@@ -218,7 +226,8 @@ const loadSetRecord = <R>(
|
||||
size: Math.max(Math.trunc(size), 0),
|
||||
has: (item: unknown) => Effect.map(ctx.call(has, source, [item]), Boolean),
|
||||
keys: () =>
|
||||
Effect.flatMap(ctx.call(keys, source, []), (result) => {
|
||||
Effect.flatMap(ctx.call(keys, source, []), (result): Effect.Effect<Iterable<unknown>> => {
|
||||
if (result instanceof IteratorObj) return Effect.succeed(result.iterator)
|
||||
if (result instanceof Arr) return Effect.succeed(result.items)
|
||||
throw typeError(`Set.${name} expected 'keys' to return an iterator.`)
|
||||
}),
|
||||
@@ -338,12 +347,18 @@ export const setGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
return undefined
|
||||
},
|
||||
],
|
||||
["keys", 0, (thisValue) => wrap(Array.from(self(thisValue, "keys").set.values()))],
|
||||
["values", 0, (thisValue) => wrap(Array.from(self(thisValue, "values").set.values()))],
|
||||
["keys", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "keys").set.values())],
|
||||
["values", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "values").set.values())],
|
||||
[
|
||||
"entries",
|
||||
0,
|
||||
(thisValue) => wrap(Array.from(self(thisValue, "entries").set.values(), (item) => wrap([item, item]))),
|
||||
(thisValue) =>
|
||||
new IteratorObj(
|
||||
builtins.Iterator,
|
||||
self(thisValue, "entries")
|
||||
.set.values()
|
||||
.map((item) => wrap([item, item])),
|
||||
),
|
||||
],
|
||||
[
|
||||
"forEach",
|
||||
@@ -365,5 +380,6 @@ export const setGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
operation("isSupersetOf"),
|
||||
operation("isDisjointFrom"),
|
||||
])
|
||||
define(proto, IteratorSymbol, get(proto, "values"), hidden)
|
||||
return set
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
SetObj,
|
||||
URLObj,
|
||||
URLSearchParamsObj,
|
||||
HeadersObj,
|
||||
} from "../interpreter/objects.js"
|
||||
import { containsOpaqueReference, isRuntimeReference } from "../interpreter/references.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
@@ -66,6 +67,7 @@ const formatConsoleValue = (value: unknown, seen: Set<object>, depth: number): s
|
||||
if (value instanceof RegExpObj) return coerceToString(value)
|
||||
if (value instanceof URLObj) return coerceToString(value)
|
||||
if (value instanceof URLSearchParamsObj) return coerceToString(value)
|
||||
if (value instanceof HeadersObj) return `Headers ${JSON.stringify(Object.fromEntries(value.headers))}`
|
||||
if (value instanceof Bytes) return `Uint8Array(${value.bytes.length}) [${value.bytes.join(",")}]`
|
||||
if (depth > MAX_CONSOLE_DEPTH) return "..."
|
||||
if (seen.has(value)) return "[Circular]"
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Effect } from "effect"
|
||||
import { constructor, methods, prototypeFrom, receiver, requiresNew } from "../interpreter/native.js"
|
||||
import { IteratorSymbol, typeError } from "../interpreter/model.js"
|
||||
import { define, entries, get, hidden, Arr, HeadersObj, IteratorObj, Obj } from "../interpreter/objects.js"
|
||||
import { applyCollectionCallback } from "../interpreter/callback.js"
|
||||
import { isRuntimeReference } from "../interpreter/references.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
import { coerceToString } from "./value.js"
|
||||
import { readPairs } from "./url.js"
|
||||
|
||||
// The host validates header names and values and throws its own TypeError; the program gets one of its own.
|
||||
const attempt = <T>(run: () => T): T => {
|
||||
try {
|
||||
return run()
|
||||
} catch (error) {
|
||||
throw typeError(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
|
||||
const constructHeaders = <R>(ctx: Interpreter<R>, init: unknown, proto: Obj): Effect.Effect<HeadersObj, unknown, R> => {
|
||||
const wrap = (headers: Headers) => new HeadersObj(proto, headers)
|
||||
if (init === undefined) return Effect.succeed(wrap(new Headers()))
|
||||
return Effect.gen(function* () {
|
||||
const pairs = init instanceof Obj ? yield* readPairs(ctx, init, "new Headers(...)") : undefined
|
||||
if (pairs !== undefined) return wrap(attempt(() => new Headers(pairs)))
|
||||
if (!(init instanceof Obj) || isRuntimeReference(init)) {
|
||||
throw typeError("new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.")
|
||||
}
|
||||
return wrap(
|
||||
attempt(() => new Headers(Object.fromEntries(entries(init).map(([key, value]) => [key, coerceToString(value)])))),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export const headersGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
const builtins = ctx.builtins
|
||||
const proto = builtins.Headers
|
||||
const headers = constructor<R>(builtins, proto, {
|
||||
name: "Headers",
|
||||
call: requiresNew("Headers"),
|
||||
construct: (args, newTarget) => constructHeaders(ctx, args[0], prototypeFrom(newTarget, proto)),
|
||||
})
|
||||
const self = (thisValue: unknown, name: string) => receiver(HeadersObj, thisValue, `Headers.prototype.${name}`)
|
||||
const wrap = (items: Array<unknown>) => new Arr(builtins.Array, items)
|
||||
const arg = (args: Array<unknown>, index: number): string => coerceToString(args[index])
|
||||
const requireArgs = (name: string, args: Array<unknown>, count: number): void => {
|
||||
if (args.length < count) throw typeError(`Headers.${name} requires ${count} argument${count === 1 ? "" : "s"}.`)
|
||||
}
|
||||
methods(builtins, proto, [
|
||||
[
|
||||
"append",
|
||||
2,
|
||||
(thisValue, args) => {
|
||||
requireArgs("append", args, 2)
|
||||
const target = self(thisValue, "append").headers
|
||||
return attempt(() => target.append(arg(args, 0), arg(args, 1)))
|
||||
},
|
||||
],
|
||||
[
|
||||
"delete",
|
||||
1,
|
||||
(thisValue, args) => {
|
||||
requireArgs("delete", args, 1)
|
||||
const target = self(thisValue, "delete").headers
|
||||
return attempt(() => target.delete(arg(args, 0)))
|
||||
},
|
||||
],
|
||||
[
|
||||
"get",
|
||||
1,
|
||||
(thisValue, args) => {
|
||||
requireArgs("get", args, 1)
|
||||
const target = self(thisValue, "get").headers
|
||||
return attempt(() => target.get(arg(args, 0)))
|
||||
},
|
||||
],
|
||||
["getSetCookie", 0, (thisValue) => wrap(self(thisValue, "getSetCookie").headers.getSetCookie())],
|
||||
[
|
||||
"has",
|
||||
1,
|
||||
(thisValue, args) => {
|
||||
requireArgs("has", args, 1)
|
||||
const target = self(thisValue, "has").headers
|
||||
return attempt(() => target.has(arg(args, 0)))
|
||||
},
|
||||
],
|
||||
[
|
||||
"set",
|
||||
2,
|
||||
(thisValue, args) => {
|
||||
requireArgs("set", args, 2)
|
||||
const target = self(thisValue, "set").headers
|
||||
return attempt(() => target.set(arg(args, 0), arg(args, 1)))
|
||||
},
|
||||
],
|
||||
// Iterator.from because Bun's Headers typings predate iterator helpers; the runtime iterators already have them.
|
||||
[
|
||||
"keys",
|
||||
0,
|
||||
(thisValue) => new IteratorObj(builtins.Iterator, Iterator.from(self(thisValue, "keys").headers.keys())),
|
||||
],
|
||||
[
|
||||
"values",
|
||||
0,
|
||||
(thisValue) => new IteratorObj(builtins.Iterator, Iterator.from(self(thisValue, "values").headers.values())),
|
||||
],
|
||||
[
|
||||
"entries",
|
||||
0,
|
||||
(thisValue) =>
|
||||
new IteratorObj(
|
||||
builtins.Iterator,
|
||||
Iterator.from(self(thisValue, "entries").headers.entries()).map(([key, value]) => wrap([key, value])),
|
||||
),
|
||||
],
|
||||
[
|
||||
"forEach",
|
||||
1,
|
||||
(thisValue, args) => {
|
||||
requireArgs("forEach", args, 1)
|
||||
const target = self(thisValue, "forEach")
|
||||
const apply = applyCollectionCallback(ctx, args[0], "Headers.forEach")
|
||||
return Effect.gen(function* () {
|
||||
for (const [key, value] of Array.from(target.headers.entries())) yield* apply([value, key, target])
|
||||
return undefined
|
||||
})
|
||||
},
|
||||
],
|
||||
])
|
||||
define(proto, IteratorSymbol, get(proto, "entries"), hidden)
|
||||
return headers
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { methods, receiver } from "../interpreter/native.js"
|
||||
import { IteratorObj, record } from "../interpreter/objects.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
|
||||
// Every built-in collection iterator shares the Iterator prototype; JS gives each collection its own, which is only
|
||||
// observable through getPrototypeOf.
|
||||
export const iteratorGlobals = <R>(ctx: Interpreter<R>): void => {
|
||||
const builtins = ctx.builtins
|
||||
methods(builtins, builtins.Iterator, [
|
||||
[
|
||||
"next",
|
||||
0,
|
||||
(thisValue) => {
|
||||
const step = receiver(IteratorObj, thisValue, "Iterator.prototype.next").iterator.next()
|
||||
return record(builtins.Object, { value: step.value, done: Boolean(step.done) })
|
||||
},
|
||||
],
|
||||
])
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Effect } from "effect"
|
||||
import { constructor, type Method, methods } from "../interpreter/native.js"
|
||||
import { constructor, fn, type Method, methods } from "../interpreter/native.js"
|
||||
import { checkArrayLength, checkStringLength } from "../interpreter/limits.js"
|
||||
import { invalidData, rangeError, typeError } from "../interpreter/model.js"
|
||||
import { Arr, PromiseObj, RegExpObj, record } from "../interpreter/objects.js"
|
||||
import { invalidData, IteratorSymbol, rangeError, typeError } from "../interpreter/model.js"
|
||||
import { define, hidden, Arr, IteratorObj, PromiseObj, RegExpObj, record } from "../interpreter/objects.js"
|
||||
import { containsOpaqueReference, typeofValue } from "../interpreter/references.js"
|
||||
import { applyCollectionCallback, isSupportedCallback } from "../interpreter/callback.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
@@ -258,5 +258,16 @@ export const stringGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
return joined
|
||||
}),
|
||||
])
|
||||
define(
|
||||
builtins.String,
|
||||
IteratorSymbol,
|
||||
fn(
|
||||
builtins,
|
||||
"[Symbol.iterator]",
|
||||
0,
|
||||
(thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "[Symbol.iterator]")[Symbol.iterator]()),
|
||||
),
|
||||
hidden,
|
||||
)
|
||||
return string
|
||||
}
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
import { Effect } from "effect"
|
||||
import { constructor, fn, type Method, methods, prototypeFrom, receiver, requiresNew } from "../interpreter/native.js"
|
||||
import { PendingThrow, typeError, uriError } from "../interpreter/model.js"
|
||||
import { defineAccessor, entries, isWrapper, Arr, Obj, URLObj, URLSearchParamsObj } from "../interpreter/objects.js"
|
||||
import { IteratorSymbol, PendingThrow, typeError, uriError } from "../interpreter/model.js"
|
||||
import {
|
||||
define,
|
||||
defineAccessor,
|
||||
entries,
|
||||
get,
|
||||
hidden,
|
||||
isWrapper,
|
||||
Arr,
|
||||
IteratorObj,
|
||||
Obj,
|
||||
URLObj,
|
||||
URLSearchParamsObj,
|
||||
} from "../interpreter/objects.js"
|
||||
import { isRuntimeReference } from "../interpreter/references.js"
|
||||
import { applyCollectionCallback, preserveConsumerError } from "../interpreter/callback.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
@@ -107,12 +119,10 @@ export const urlGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
return url
|
||||
}
|
||||
|
||||
const readPair = <R>(ctx: Interpreter<R>, value: unknown): Effect.Effect<Array<string>, unknown, R> =>
|
||||
const readPair = <R>(ctx: Interpreter<R>, value: unknown, label: string): Effect.Effect<Array<string>, unknown, R> =>
|
||||
Effect.gen(function* () {
|
||||
const cursor = yield* ctx.iterate(value)
|
||||
if (cursor === undefined) {
|
||||
throw typeError("new URLSearchParams(...) expects iterable [name, value] pairs.")
|
||||
}
|
||||
if (cursor === undefined) throw typeError(`${label} expects iterable [name, value] pairs.`)
|
||||
const items: Array<string> = []
|
||||
while (true) {
|
||||
const step = yield* cursor.next
|
||||
@@ -126,6 +136,29 @@ const readPair = <R>(ctx: Interpreter<R>, value: unknown): Effect.Effect<Array<s
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Reads a synchronous iterable of `[name, value]` pairs as strings; `undefined` when `init` is not iterable. As in
|
||||
* WebIDL, the whole sequence is converted before any pair's length is checked.
|
||||
*/
|
||||
export const readPairs = <R>(
|
||||
ctx: Interpreter<R>,
|
||||
init: unknown,
|
||||
label: string,
|
||||
): Effect.Effect<Array<[string, string]> | undefined, unknown, R> =>
|
||||
Effect.gen(function* () {
|
||||
const cursor = yield* ctx.iterate(init)
|
||||
if (cursor === undefined) return undefined
|
||||
const pairs: Array<Array<string>> = []
|
||||
while (true) {
|
||||
const step = yield* cursor.next
|
||||
if (step.done) {
|
||||
if (pairs.some((entry) => entry.length !== 2)) throw typeError(`${label} expects iterable [name, value] pairs.`)
|
||||
return pairs as Array<[string, string]>
|
||||
}
|
||||
pairs.push(yield* preserveConsumerError(cursor, readPair(ctx, step.value, label)))
|
||||
}
|
||||
})
|
||||
|
||||
const constructURLSearchParams = <R>(
|
||||
ctx: Interpreter<R>,
|
||||
init: unknown,
|
||||
@@ -139,20 +172,8 @@ const constructURLSearchParams = <R>(
|
||||
return Effect.succeed(wrap(new URLSearchParams(coerceToString(init))))
|
||||
}
|
||||
return Effect.gen(function* () {
|
||||
const cursor = yield* ctx.iterate(init)
|
||||
if (cursor !== undefined) {
|
||||
const pairs: Array<Array<string>> = []
|
||||
while (true) {
|
||||
const step = yield* cursor.next
|
||||
if (step.done) {
|
||||
if (pairs.some((entry) => entry.length !== 2)) {
|
||||
throw typeError("new URLSearchParams(...) expects iterable [name, value] pairs.")
|
||||
}
|
||||
return wrap(new URLSearchParams(pairs.map((entry): [string, string] => [entry[0] ?? "", entry[1] ?? ""])))
|
||||
}
|
||||
pairs.push(yield* preserveConsumerError(cursor, readPair(ctx, step.value)))
|
||||
}
|
||||
}
|
||||
const pairs = yield* readPairs(ctx, init, "new URLSearchParams(...)")
|
||||
if (pairs !== undefined) return wrap(new URLSearchParams(pairs))
|
||||
if (isRuntimeReference(init)) {
|
||||
throw typeError("new URLSearchParams(...) expects a query string, data object, or synchronous iterable pairs.")
|
||||
}
|
||||
@@ -249,13 +270,18 @@ export const urlSearchParamsGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
return undefined
|
||||
},
|
||||
],
|
||||
["keys", 0, (thisValue) => wrap(Array.from(self(thisValue, "keys").params.keys()))],
|
||||
["values", 0, (thisValue) => wrap(Array.from(self(thisValue, "values").params.values()))],
|
||||
["keys", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "keys").params.keys())],
|
||||
["values", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "values").params.values())],
|
||||
[
|
||||
"entries",
|
||||
0,
|
||||
(thisValue) =>
|
||||
wrap(Array.from(self(thisValue, "entries").params.entries(), ([key, value]) => wrap([key, value]))),
|
||||
new IteratorObj(
|
||||
builtins.Iterator,
|
||||
self(thisValue, "entries")
|
||||
.params.entries()
|
||||
.map(([key, value]) => wrap([key, value])),
|
||||
),
|
||||
],
|
||||
["toString", 0, (thisValue) => self(thisValue, "toString").params.toString()],
|
||||
[
|
||||
@@ -272,5 +298,6 @@ export const urlSearchParamsGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
},
|
||||
],
|
||||
])
|
||||
define(proto, IteratorSymbol, get(proto, "entries"), hidden)
|
||||
return searchParams
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
SetObj,
|
||||
URLObj,
|
||||
URLSearchParamsObj,
|
||||
HeadersObj,
|
||||
} from "../interpreter/objects.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
|
||||
@@ -28,6 +29,7 @@ export const coerceToString = (value: unknown): string => {
|
||||
if (value instanceof SetObj) return "[object Set]"
|
||||
if (value instanceof URLObj) return value.url.href
|
||||
if (value instanceof URLSearchParamsObj) return value.params.toString()
|
||||
if (value instanceof HeadersObj) return "[object Headers]"
|
||||
if (value instanceof Bytes) return value.bytes.join(",")
|
||||
if (value instanceof ErrorObj) {
|
||||
// Match Error.prototype.toString: "name: message", or just one when the other is empty.
|
||||
|
||||
@@ -298,21 +298,18 @@ const cases = [
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/keys/iteration.js",
|
||||
code: `return ["a", "b", "c"].keys()`,
|
||||
expected: [0, 1, 2],
|
||||
code: `const it = ["a", "b", "c"].keys(); return [it.next(), it.next(), it.next(), it.next()]`,
|
||||
expected: [{ value: 0, done: false }, { value: 1, done: false }, { value: 2, done: false }, { done: true }],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/values/iteration.js",
|
||||
code: `return ["a", "b", "c"].values()`,
|
||||
expected: ["a", "b", "c"],
|
||||
code: `const it = ["a", "b", "c"].values(); return [it.next(), it.next(), it.next(), it.next()]`,
|
||||
expected: [{ value: "a", done: false }, { value: "b", done: false }, { value: "c", done: false }, { done: true }],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/prototype/entries/iteration.js",
|
||||
code: `return ["a", "b"].entries()`,
|
||||
expected: [
|
||||
[0, "a"],
|
||||
[1, "b"],
|
||||
],
|
||||
code: `const it = ["a", "b"].entries(); return [it.next(), it.next(), it.next()]`,
|
||||
expected: [{ value: [0, "a"], done: false }, { value: [1, "b"], done: false }, { done: true }],
|
||||
},
|
||||
{
|
||||
path: "test/built-ins/Array/isArray/15.4.3.2-0-3.js",
|
||||
|
||||
@@ -15,6 +15,12 @@ const error = async (code: string) => {
|
||||
return result.error
|
||||
}
|
||||
|
||||
describe("source syntax", () => {
|
||||
test("rejects TypeScript-only syntax", async () => {
|
||||
expect((await error(`const value: number = 1; return value`)).kind).toBe("ParseError")
|
||||
})
|
||||
})
|
||||
|
||||
describe("error identity", () => {
|
||||
test("awaiting the same rejected promise twice yields the same error object", async () => {
|
||||
expect(
|
||||
@@ -101,7 +107,7 @@ describe("host errors escaping built-ins", () => {
|
||||
test("report the location of the call that raised them", async () => {
|
||||
const failure = await error(`return [1].map((n) => n.toFixed(200))`)
|
||||
expect(failure.kind).toBe("ExecutionFailure")
|
||||
expect(failure.message).toBe("RangeError: toFixed() argument must be between 0 and 100 (line 1, col 23)")
|
||||
expect(failure.message).toBe("RangeError: toFixed() argument must be between 0 and 100 (line 1, col 19)")
|
||||
})
|
||||
|
||||
test("a built-in that rejects its arguments before doing any work is located at the call", async () => {
|
||||
@@ -109,13 +115,13 @@ describe("host errors escaping built-ins", () => {
|
||||
})
|
||||
|
||||
test("a rejection born inside a promise the built-in created is located at the creating call", async () => {
|
||||
expect((await error(`return await Promise.all(1)`)).message).toEndWith("(line 1, col 14)")
|
||||
expect((await error(`return await Promise.race([])`)).message).toEndWith("(line 1, col 14)")
|
||||
expect((await error(`return await Promise.all(1)`)).message).toEndWith("(line 1, col 10)")
|
||||
expect((await error(`return await Promise.race([])`)).message).toEndWith("(line 1, col 10)")
|
||||
expect((await error(`return await Promise.all({ [Symbol.iterator]: () => ({ next: 1 }) })`)).message).toEndWith(
|
||||
"(line 1, col 14)",
|
||||
"(line 1, col 10)",
|
||||
)
|
||||
expect((await error(`let p; p = Promise.resolve().then(() => p); return await p`)).message).toEndWith(
|
||||
"(line 2, col 5)",
|
||||
"(line 1, col 8)",
|
||||
)
|
||||
})
|
||||
|
||||
@@ -128,7 +134,7 @@ describe("host errors escaping built-ins", () => {
|
||||
|
||||
test("a failure inside a built-in called by another built-in is located at the outer call", async () => {
|
||||
const failure = await error(`return Array.from({ [Symbol.iterator]: () => ({ next: 1 }) })`)
|
||||
expect(failure.message).toBe("TypeError: Iterator next must be a function. (line 1, col 8)")
|
||||
expect(failure.message).toBe("TypeError: Iterator next must be a function. (line 1, col 4)")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -147,7 +153,7 @@ describe("call depth", () => {
|
||||
test("uncaught overflow reports the call that overflowed", async () => {
|
||||
const failure = await error(`const f = (n) => f(n + 1); return f(0)`)
|
||||
expect(failure.kind).toBe("ExecutionFailure")
|
||||
expect(failure.message).toBe("RangeError: Maximum call stack size exceeded (line 1, col 18)")
|
||||
expect(failure.message).toBe("RangeError: Maximum call stack size exceeded (line 1, col 14)")
|
||||
})
|
||||
|
||||
test("the limit is 10000 nested calls", async () => {
|
||||
|
||||
@@ -128,6 +128,34 @@ describe("values are converted at the boundary, never shared", () => {
|
||||
expect([...(held[0] as Set<{ z: number }>)][0]).toEqual({ z: 1 })
|
||||
})
|
||||
|
||||
test("Headers cross as copies in both directions", async () => {
|
||||
const stored = new Headers({ "X-A": "1" })
|
||||
const target = CodeMode.make({
|
||||
extensions: [
|
||||
Extension.make({
|
||||
name: "http",
|
||||
globals: {
|
||||
headers: () => stored,
|
||||
keep: (value: Headers) => {
|
||||
held.push(value)
|
||||
return value
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
held.length = 0
|
||||
expect(
|
||||
await value(
|
||||
`const h = headers(); h.set("x-a", "2"); const back = keep(h); back.set("x-a", "3"); return [h instanceof Headers, h.get("x-a"), back === h, back.get("x-a"), [...back]]`,
|
||||
target,
|
||||
),
|
||||
).toEqual([true, "2", false, "3", [["x-a", "3"]]])
|
||||
expect(stored.get("x-a")).toBe("1")
|
||||
expect(held[0]).toBeInstanceOf(Headers)
|
||||
expect((held[0] as Headers).get("x-a")).toBe("2")
|
||||
})
|
||||
|
||||
test("bytes cross as copies in both directions; ArrayBuffer comes in as Uint8Array", async () => {
|
||||
const stored = new Uint8Array([1, 2, 3])
|
||||
const target = CodeMode.make({
|
||||
@@ -192,8 +220,9 @@ describe("values are converted at the boundary, never shared", () => {
|
||||
expect(held[1]).toBeInstanceOf(Error)
|
||||
})
|
||||
|
||||
test("functions, promises, and symbols cannot be passed in", async () => {
|
||||
test("functions, promises, iterators, and symbols cannot be passed in", async () => {
|
||||
expect((await failure(`keep(() => 1)`)).message).toContain("Argument 1 to keep contains a function")
|
||||
expect((await failure(`keep([1].keys())`)).message).toContain("Argument 1 to keep contains an iterator")
|
||||
expect((await failure(`keep(later(1))`)).message).toContain("un-awaited Promise")
|
||||
expect((await failure(`keep(Symbol.iterator)`)).message).toContain("Argument 1 to keep contains a symbol")
|
||||
})
|
||||
|
||||
@@ -122,10 +122,10 @@ describe("Map.groupBy Test262 parity", () => {
|
||||
grouped.get(1),
|
||||
grouped.get("1"),
|
||||
grouped.has(stringable),
|
||||
grouped.keys().length,
|
||||
[...grouped.keys()].length,
|
||||
parity.get("even"),
|
||||
parity.get("odd"),
|
||||
lengths.keys(),
|
||||
[...lengths.keys()],
|
||||
lengths.get(5),
|
||||
lengths.get(4),
|
||||
]
|
||||
@@ -162,7 +162,7 @@ describe("Map.groupBy Test262 parity", () => {
|
||||
await value(`
|
||||
const grouped = Map.groupBy("🥰💩🙏😈", (char) => char < "🙏" ? "before" : "after")
|
||||
const empty = Map.groupBy([], () => { throw new Error("not called") })
|
||||
return [grouped.keys(), grouped.get("before"), grouped.get("after"), empty.size]
|
||||
return [[...grouped.keys()], grouped.get("before"), grouped.get("after"), empty.size]
|
||||
`),
|
||||
).toEqual([["after", "before"], ["💩", "😈"], ["🥰", "🙏"], 0])
|
||||
})
|
||||
|
||||
@@ -502,9 +502,9 @@ describe("CodeMode-specific array behavior", () => {
|
||||
expect(err.message).toContain("circular")
|
||||
})
|
||||
|
||||
test("keys/values/entries return arrays usable with for...of and spread", async () => {
|
||||
test("keys/values/entries return iterators usable with for...of and spread", async () => {
|
||||
expect(await value(`return [...["x","y","z"].keys()]`)).toEqual([0, 1, 2])
|
||||
expect(await value(`return ["x","y"].values()`)).toEqual(["x", "y"])
|
||||
expect(await value(`return [...["x","y"].values()]`)).toEqual(["x", "y"])
|
||||
expect(
|
||||
await value(`
|
||||
const out = []
|
||||
|
||||
@@ -198,10 +198,10 @@ describe("Set composition Test262 parity", () => {
|
||||
keys: () => [-0],
|
||||
}
|
||||
return [
|
||||
1 / new Set([1]).union(setlike).values()[1] === Infinity,
|
||||
1 / new Set([0, 1, 2]).intersection(setlike).values()[0] === Infinity,
|
||||
1 / [...new Set([1]).union(setlike)][1] === Infinity,
|
||||
1 / [...new Set([0, 1, 2]).intersection(setlike)][0] === Infinity,
|
||||
[...new Set([0, 1]).difference(setlike)],
|
||||
1 / new Set([1, 2]).symmetricDifference(setlike).values()[2] === Infinity,
|
||||
1 / [...new Set([1, 2]).symmetricDifference(setlike)][2] === Infinity,
|
||||
]
|
||||
`),
|
||||
).toEqual([true, true, [1], true])
|
||||
|
||||
@@ -635,6 +635,154 @@ describe("URL and URI helpers", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("Headers", () => {
|
||||
test("constructs from records, pairs, Maps, and Headers; names fold to lowercase and values combine", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const headers = new Headers({ "Content-Type": "text/plain", "X-Count": 1, "X-Null": null })
|
||||
headers.append("Accept", "text/html")
|
||||
headers.append("accept", "application/json")
|
||||
headers.set("x-count", "2")
|
||||
headers.delete("x-null")
|
||||
const copy = new Headers(headers)
|
||||
copy.set("content-type", "text/html")
|
||||
return {
|
||||
get: headers.get("content-type"),
|
||||
missing: headers.get("x-missing"),
|
||||
combined: headers.get("ACCEPT"),
|
||||
has: [headers.has("Accept"), headers.has("x-null")],
|
||||
count: headers.get("x-count"),
|
||||
copied: [headers.get("content-type"), copy.get("content-type")],
|
||||
pairs: [...new Headers([["b", "2"], ["A", "1"]])],
|
||||
map: [...new Headers(new Map([["k", "v"]]))],
|
||||
keys: [...headers.keys()],
|
||||
values: [...headers.values()],
|
||||
entries: [...headers.entries()],
|
||||
}
|
||||
`),
|
||||
).toEqual({
|
||||
get: "text/plain",
|
||||
missing: null,
|
||||
combined: "text/html, application/json",
|
||||
has: [true, false],
|
||||
count: "2",
|
||||
copied: ["text/plain", "text/html"],
|
||||
pairs: [
|
||||
["a", "1"],
|
||||
["b", "2"],
|
||||
],
|
||||
map: [["k", "v"]],
|
||||
keys: ["accept", "content-type", "x-count"],
|
||||
values: ["text/html, application/json", "text/plain", "2"],
|
||||
entries: [
|
||||
["accept", "text/html, application/json"],
|
||||
["content-type", "text/plain"],
|
||||
["x-count", "2"],
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("iterates in sorted order everywhere iteration is allowed, and getSetCookie keeps cookies apart", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const headers = new Headers({ b: "2", a: "1" })
|
||||
headers.append("Set-Cookie", "x=1")
|
||||
headers.append("set-cookie", "y=2")
|
||||
const seen = []
|
||||
headers.forEach((value, name, self) => seen.push(name + "=" + value + ":" + (self === headers)))
|
||||
const [first] = headers
|
||||
function* pairs() { yield* headers }
|
||||
return {
|
||||
seen,
|
||||
first,
|
||||
spread: [...headers],
|
||||
from: Array.from(headers).length,
|
||||
generator: [...pairs()].length,
|
||||
object: Object.fromEntries(headers),
|
||||
cookies: headers.getSetCookie(),
|
||||
}
|
||||
`),
|
||||
).toEqual({
|
||||
seen: ["a=1:true", "b=2:true", "set-cookie=x=1:true", "set-cookie=y=2:true"],
|
||||
first: ["a", "1"],
|
||||
spread: [
|
||||
["a", "1"],
|
||||
["b", "2"],
|
||||
["set-cookie", "x=1"],
|
||||
["set-cookie", "y=2"],
|
||||
],
|
||||
from: 4,
|
||||
generator: 4,
|
||||
object: { a: "1", b: "2", "set-cookie": "y=2" },
|
||||
cookies: ["x=1", "y=2"],
|
||||
})
|
||||
})
|
||||
|
||||
test("serializes as a name-to-value object at the boundary and in JSON; prints for console", async () => {
|
||||
const result = await run(`
|
||||
const headers = new Headers({ "X-A": "1", b: "2" })
|
||||
console.log(headers)
|
||||
return { headers, json: JSON.stringify({ headers }), text: String(headers), type: typeof headers, is: headers instanceof Headers }
|
||||
`)
|
||||
expect(result.ok && result.value).toEqual({
|
||||
headers: { b: "2", "x-a": "1" },
|
||||
json: '{"headers":{"b":"2","x-a":"1"}}',
|
||||
text: "[object Headers]",
|
||||
type: "object",
|
||||
is: true,
|
||||
})
|
||||
expect(result.ok && result.logs?.[0]).toBe('Headers {"b":"2","x-a":"1"}')
|
||||
})
|
||||
|
||||
test("rejects what it cannot build from, and invalid names and values, with TypeErrors the program can catch", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
function message(run) {
|
||||
try { run(); return null } catch (error) { return error instanceof TypeError ? error.message : error }
|
||||
}
|
||||
const headers = new Headers()
|
||||
return [
|
||||
message(() => Headers()),
|
||||
message(() => new Headers(null)),
|
||||
message(() => new Headers(1)),
|
||||
message(() => new Headers("a=1")),
|
||||
message(() => new Headers(new Date())),
|
||||
message(() => new Headers(() => 1)),
|
||||
message(() => new Headers([["name"]])),
|
||||
message(() => new Headers([["a", "b", "c"]])),
|
||||
message(() => new Headers({ "bad name": "x" })),
|
||||
message(() => new Headers({ name: "bad\u0000value" })),
|
||||
message(() => headers.get("invalid\u0100")),
|
||||
message(() => headers.has({})),
|
||||
message(() => headers.set("a", "invalid\u0100")),
|
||||
message(() => headers.append("a")),
|
||||
message(() => headers.forEach()),
|
||||
message(() => headers.forEach(1)),
|
||||
message(() => { const get = headers.get; return get("a") }),
|
||||
]
|
||||
`),
|
||||
).toEqual([
|
||||
"Constructor Headers requires 'new'.",
|
||||
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
|
||||
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
|
||||
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
|
||||
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
|
||||
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
|
||||
"new Headers(...) expects iterable [name, value] pairs.",
|
||||
"new Headers(...) expects iterable [name, value] pairs.",
|
||||
expect.stringContaining("bad name"),
|
||||
expect.stringContaining("invalid value"),
|
||||
expect.stringContaining("Invalid header name"),
|
||||
expect.stringContaining("[object Object]"),
|
||||
expect.stringContaining("invalid value"),
|
||||
"Headers.append requires 2 arguments.",
|
||||
"Headers.forEach requires 1 argument.",
|
||||
"Headers.forEach expects a function callback.",
|
||||
"Headers.prototype.get called on incompatible receiver undefined.",
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("Map", () => {
|
||||
test("get/set/has/size with chaining", async () => {
|
||||
expect(
|
||||
@@ -668,18 +816,28 @@ describe("Map", () => {
|
||||
expect((await error(`return new Map(["flat"])`)).message).toMatch(/\[key, value\] pairs/)
|
||||
})
|
||||
|
||||
test("keys/values/entries return arrays", async () => {
|
||||
test("keys/values/entries return live iterators", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const m = new Map([["a", 1], ["b", 2]])
|
||||
return { keys: m.keys(), values: m.values(), entries: m.entries() }
|
||||
const keys = m.keys()
|
||||
const first = keys.next()
|
||||
m.set("c", 3)
|
||||
return { first, rest: [...keys], values: [...m.values()], entries: [...m.entries()], same: [...m[Symbol.iterator]()] }
|
||||
`),
|
||||
).toEqual({
|
||||
keys: ["a", "b"],
|
||||
values: [1, 2],
|
||||
first: { value: "a", done: false },
|
||||
rest: ["b", "c"],
|
||||
values: [1, 2, 3],
|
||||
entries: [
|
||||
["a", 1],
|
||||
["b", 2],
|
||||
["c", 3],
|
||||
],
|
||||
same: [
|
||||
["a", 1],
|
||||
["b", 2],
|
||||
["c", 3],
|
||||
],
|
||||
})
|
||||
})
|
||||
@@ -971,6 +1129,108 @@ describe("TextEncoder and TextDecoder", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("built-in iterators", () => {
|
||||
test("keys/values/entries and [Symbol.iterator] step with next() and stay live", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const items = ["a"]
|
||||
const it = items.entries()
|
||||
items.push("b")
|
||||
const steps = [it.next(), it.next(), it.next()]
|
||||
items.push("c")
|
||||
return { steps, after: it.next(), same: items[Symbol.iterator] === items.values }
|
||||
`),
|
||||
).toEqual({
|
||||
steps: [{ value: [0, "a"], done: false }, { value: [1, "b"], done: false }, { done: true }],
|
||||
after: { done: true },
|
||||
same: true,
|
||||
})
|
||||
expect(
|
||||
await value(`
|
||||
const s = new Set([1, 2])
|
||||
const u = new URLSearchParams("a=1&b=2")
|
||||
const h = new Headers({ b: "2", a: "1" })
|
||||
const bytes = new Uint8Array([7, 8])
|
||||
return [
|
||||
[...s.entries()], [...s[Symbol.iterator]()], s[Symbol.iterator] === s.values,
|
||||
[...u.keys()], [...u[Symbol.iterator]()], u[Symbol.iterator] === u.entries,
|
||||
[...h.values()], [...h[Symbol.iterator]()], h[Symbol.iterator] === h.entries,
|
||||
[...bytes.entries()], [...bytes[Symbol.iterator]()], bytes[Symbol.iterator] === bytes.values,
|
||||
[..."ab"[Symbol.iterator]()],
|
||||
]
|
||||
`),
|
||||
).toEqual([
|
||||
[
|
||||
[1, 1],
|
||||
[2, 2],
|
||||
],
|
||||
[1, 2],
|
||||
true,
|
||||
["a", "b"],
|
||||
[
|
||||
["a", "1"],
|
||||
["b", "2"],
|
||||
],
|
||||
true,
|
||||
["1", "2"],
|
||||
[
|
||||
["a", "1"],
|
||||
["b", "2"],
|
||||
],
|
||||
true,
|
||||
[
|
||||
[0, 7],
|
||||
[1, 8],
|
||||
],
|
||||
[7, 8],
|
||||
true,
|
||||
["a", "b"],
|
||||
])
|
||||
})
|
||||
|
||||
test("iterators are consumed once by every iteration site", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const it = [1, 2, 3, 4].values()
|
||||
const picked = []
|
||||
for (const item of it) { picked.push(item); if (item === 2) break }
|
||||
const [third] = it
|
||||
return { picked, third, rest: [...it], spent: Array.from(it), again: it[Symbol.iterator]() === it }
|
||||
`),
|
||||
).toEqual({ picked: [1, 2], third: 3, rest: [4], spent: [], again: true })
|
||||
expect(
|
||||
await value(`
|
||||
const m = new Map([["a", 1], ["b", 2]])
|
||||
return [
|
||||
Object.fromEntries(m.entries()), Array.from(m.keys(), (k) => k + "!"), new Set(m.values()).size,
|
||||
await Promise.all([Promise.resolve(1), 2].values()),
|
||||
]
|
||||
`),
|
||||
).toEqual([{ a: 1, b: 2 }, ["a!", "b!"], 2, [1, 2]])
|
||||
expect(await value(`let s = 0; for await (const v of [Promise.resolve(1), 2].values()) s += v; return s`)).toBe(3)
|
||||
expect(
|
||||
await value(`return new Set([1, 2]).union({ size: 1, has: () => false, keys: () => new Set([3]).keys() })`),
|
||||
).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
test("iterators are opaque references", async () => {
|
||||
expect(await value(`return [1].keys()`)).toEqual({})
|
||||
expect(await value(`return JSON.stringify({ it: [1].keys() })`)).toBe('{"it":{}}')
|
||||
expect(await value(`return [typeof [1].keys(), Array.isArray([1].keys()), Object.keys([1].keys())]`)).toEqual([
|
||||
"object",
|
||||
false,
|
||||
[],
|
||||
])
|
||||
const logged = await run(`console.log([1].keys()); return null`)
|
||||
expect(logged.logs?.[0]).toBe("[opaque reference]")
|
||||
expect((await error(`return [1].keys() + ""`)).message).toContain("Binary operators require data values")
|
||||
expect((await error(`return [1].keys().next.call({})`)).message).toContain("is not a function")
|
||||
expect((await error(`const it = [1].keys(); const next = it.next; return next()`)).message).toContain(
|
||||
"Iterator.prototype.next called on incompatible receiver undefined",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("stdlib integration", () => {
|
||||
test("constructor follows own keys, shadowing, writes, and new", async () => {
|
||||
expect(
|
||||
|
||||
@@ -13,8 +13,6 @@ built-ins/Array/prototype/copyWithin/coerced-values-target.js # Array.copyWithi
|
||||
built-ins/Array/prototype/copyWithin/return-abrupt-from-end.js # Expected a Test262Error but got a TypeError
|
||||
built-ins/Array/prototype/copyWithin/return-abrupt-from-start.js # Expected a Test262Error but got a TypeError
|
||||
built-ins/Array/prototype/copyWithin/return-abrupt-from-target.js # Expected a Test262Error but got a TypeError
|
||||
built-ins/Array/prototype/entries/iteration-mutable.js # .next is not a function.
|
||||
built-ins/Array/prototype/entries/iteration.js # .next is not a function.
|
||||
built-ins/Array/prototype/every/15.4.4.16-7-6.js # res Expected SameValue(«true», «false») to be true
|
||||
built-ins/Array/prototype/every/15.4.4.16-7-c-i-8.js # [, , , ].every(callbackfn) Expected SameValue(«true», «false») to be true
|
||||
built-ins/Array/prototype/every/15.4.4.16-8-10.js # … cannot be constructed: user-defined constructors and classes are not supported. Call it as a funct
|
||||
@@ -86,8 +84,6 @@ built-ins/Array/prototype/join/S15.4.4.5_A3.1_T2.js # Array.join expects zero a
|
||||
built-ins/Array/prototype/join/S15.4.4.5_A3.2_T2.js # x.join() must return "*" Expected SameValue(«"[object Object]"», «"*"») to be true
|
||||
built-ins/Array/prototype/join/S15.4.4.5_A4_T3.js # Array.prototype.join called on incompatible receiver a data object.
|
||||
built-ins/Array/prototype/join/S15.4.4.5_A5_T1.js # #1: Array.prototype[1] = 1; x = [0]; x.length = 2; x.join() === "0,1". Actual: 0,
|
||||
built-ins/Array/prototype/keys/iteration-mutable.js # .next is not a function.
|
||||
built-ins/Array/prototype/keys/iteration.js # .next is not a function.
|
||||
built-ins/Array/prototype/lastIndexOf/15.4.4.15-5-1.js # Array.lastIndexOf expects start index to be a number.
|
||||
built-ins/Array/prototype/lastIndexOf/15.4.4.15-5-15.js # Array.lastIndexOf expects start index to be a number.
|
||||
built-ins/Array/prototype/lastIndexOf/15.4.4.15-5-16.js # Array.lastIndexOf expects start index to be a number.
|
||||
@@ -241,9 +237,6 @@ built-ins/Array/prototype/unshift/S15.4.4.13_A2_T3.js # Array.prototype.unshift
|
||||
built-ins/Array/prototype/unshift/S15.4.4.13_A3_T2.js # Array.prototype.unshift called on incompatible receiver a data object.
|
||||
built-ins/Array/prototype/unshift/S15.4.4.13_A4_T1.js # Array.prototype.unshift called on incompatible receiver a data object.
|
||||
built-ins/Array/prototype/unshift/S15.4.4.13_A4_T2.js # #3: Array.prototype[0] = 1; x = []; x.length = 1; x.unshift(0); x[1] === 1. Actual: undefined
|
||||
built-ins/Array/prototype/values/iteration-mutable.js # .next is not a function.
|
||||
built-ins/Array/prototype/values/iteration.js # .next is not a function.
|
||||
language/statements/async-function/rest-params-trailing-comma-early-error.js # expected SyntaxError but the program ran
|
||||
language/statements/async-generator/dflt-params-abrupt.js # Expected a Test262Error to be thrown but no exception was thrown at all
|
||||
language/statements/async-generator/dflt-params-ref-later.js # Expected a ReferenceError to be thrown but no exception was thrown at all
|
||||
language/statements/async-generator/dflt-params-ref-self.js # Expected a ReferenceError to be thrown but no exception was thrown at all
|
||||
@@ -291,16 +284,13 @@ language/statements/async-generator/dstr/obj-ptrn-prop-id-init-throws.js # Expe
|
||||
language/statements/async-generator/dstr/obj-ptrn-prop-id-init-unresolvable.js # Expected a ReferenceError to be thrown but no exception was thrown at all
|
||||
language/statements/async-generator/dstr/obj-ptrn-prop-obj-value-null.js # Expected a TypeError to be thrown but no exception was thrown at all
|
||||
language/statements/async-generator/dstr/obj-ptrn-prop-obj-value-undef.js # Expected a TypeError to be thrown but no exception was thrown at all
|
||||
language/statements/async-generator/rest-params-trailing-comma-early-error.js # expected SyntaxError but the program ran
|
||||
language/statements/async-generator/return-undefined-implicit-and-explicit.js # Actual ["tick 1", "tick 2", "g1 ret", "g2 ret", "g3 ret", "g4 ret"] and expected ["tick 1", "g1 ret"
|
||||
language/statements/const/dstr/ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
|
||||
language/statements/for-await-of/async-func-decl-dstr-array-elem-init-in.js # Failed to parse TypeScript: '…' expected.
|
||||
language/statements/for-await-of/async-func-decl-dstr-obj-empty-bool.js # TypeError: Object destructuring requires a data object or array value, received a boolean.
|
||||
language/statements/for-await-of/async-func-decl-dstr-obj-empty-num.js # TypeError: Object destructuring requires a data object or array value, received a number.
|
||||
language/statements/for-await-of/async-func-decl-dstr-obj-empty-string.js # TypeError: Object destructuring requires a data object or array value, received a string.
|
||||
language/statements/for-await-of/async-func-decl-dstr-obj-rest-number.js # TypeError: Object destructuring requires a data object or array value, received a number.
|
||||
language/statements/for-await-of/async-func-decl-dstr-obj-rest-str-val.js # TypeError: Object destructuring requires a data object or array value, received a string.
|
||||
language/statements/for-await-of/async-gen-decl-dstr-array-elem-init-in.js # Failed to parse TypeScript: '…' expected.
|
||||
language/statements/for-await-of/async-gen-decl-dstr-array-elem-iter-rtrn-close-null.js # "Promise incorrectly fulfilled."
|
||||
language/statements/for-await-of/async-gen-decl-dstr-obj-empty-bool.js # TypeError: Object destructuring requires a data object or array value, received a boolean.
|
||||
language/statements/for-await-of/async-gen-decl-dstr-obj-empty-num.js # TypeError: Object destructuring requires a data object or array value, received a number.
|
||||
@@ -308,8 +298,6 @@ language/statements/for-await-of/async-gen-decl-dstr-obj-empty-string.js # Type
|
||||
language/statements/for-await-of/async-gen-decl-dstr-obj-rest-number.js # TypeError: Object destructuring requires a data object or array value, received a number.
|
||||
language/statements/for-await-of/async-gen-decl-dstr-obj-rest-str-val.js # TypeError: Object destructuring requires a data object or array value, received a string.
|
||||
language/statements/for-in/head-lhs-member.js # Unsupported for...in binding.
|
||||
language/statements/for-of/Array.prototype.Symbol.iterator.js # The called value is not a function.
|
||||
language/statements/for-of/dstr/array-elem-init-in.js # Failed to parse TypeScript: '…' expected.
|
||||
language/statements/for-of/dstr/array-elem-iter-rtrn-close-err.js # Iterator next must be a function.
|
||||
language/statements/for-of/dstr/array-elem-iter-rtrn-close-null.js # Expected a TypeError to be thrown but no exception was thrown at all
|
||||
language/statements/for-of/dstr/array-elem-iter-thrw-close-err.js # Expected SameValue(«1», «0») to be true
|
||||
@@ -355,7 +343,6 @@ language/statements/function/S14_A5_T1.js # Identifier '…' has already been d
|
||||
language/statements/function/S14_A5_T2.js # Identifier '…' has already been declared.
|
||||
language/statements/function/dstr/ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
|
||||
language/statements/function/dstr/dflt-ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
|
||||
language/statements/function/rest-params-trailing-comma-early-error.js # expected SyntaxError but the program ran
|
||||
language/statements/generators/dflt-params-abrupt.js # Expected a Test262Error to be thrown but no exception was thrown at all
|
||||
language/statements/generators/dflt-params-ref-later.js # Expected a ReferenceError to be thrown but no exception was thrown at all
|
||||
language/statements/generators/dflt-params-ref-self.js # Expected a ReferenceError to be thrown but no exception was thrown at all
|
||||
@@ -406,8 +393,6 @@ language/statements/generators/dstr/obj-ptrn-prop-obj-value-undef.js # Expected
|
||||
language/statements/generators/has-instance.js # The right-hand side of '…' has no '…' object.
|
||||
language/statements/generators/prototype-typeof.js # Expected SameValue(«"undefined"», «"object"») to be true
|
||||
language/statements/generators/prototype-uniqueness.js # Expected true but got false
|
||||
language/statements/generators/rest-params-trailing-comma-early-error.js # expected SyntaxError but the program ran
|
||||
language/statements/labeled/value-await-non-module-escaped.js # Failed to parse TypeScript: Keywords cannot contain escape characters.
|
||||
language/statements/labeled/value-await-non-module.js # Failed to parse TypeScript: Expression expected.
|
||||
language/statements/let/dstr/ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
|
||||
language/statements/return/S12.9_A1_T1.js # expected SyntaxError but the program ran
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
* - html/webappapis/atob/base64.any.js (btoa reference encoder, input list, and atob WebIDL cases)
|
||||
* - fetch/data-urls/resources/base64.json (copied to fixtures/wpt-base64.json)
|
||||
* - WebCryptoAPI/randomUUID.https.any.js
|
||||
* - fetch/api/headers/{headers-basic,headers-errors}.any.js
|
||||
*
|
||||
* Copyright © web-platform-tests contributors. Governed by the 3-Clause BSD license in LICENSE.wpt.
|
||||
*
|
||||
* `assert_throws_dom("InvalidCharacterError", …)` becomes a check for a TypeError: CodeMode has no DOMException.
|
||||
* `checkIteratorProperties` (prototype chain and property descriptors) and the custom iterator on a Headers
|
||||
* instance are left out.
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
@@ -166,3 +169,256 @@ describe("crypto.randomUUID WPT parity (WebCryptoAPI/randomUUID.https.any.js)",
|
||||
).toEqual([true, true, true, 768])
|
||||
})
|
||||
})
|
||||
|
||||
// Enough of testharness.js to run the Headers files close to verbatim; each `test` records its failure, if any.
|
||||
const testharness = `
|
||||
const failures = []
|
||||
function test(run, name) { try { run() } catch (error) { failures.push(name + ": " + (error && error.message ? error.message : error)) } }
|
||||
function assert_equals(actual, expected, message) { if (actual !== expected) throw new Error((message || "") + " expected " + JSON.stringify(expected) + " got " + JSON.stringify(actual)) }
|
||||
function assert_true(actual, message) { assert_equals(actual, true, message) }
|
||||
function assert_false(actual, message) { assert_equals(actual, false, message) }
|
||||
function assert_array_equals(actual, expected, message) { assert_equals(JSON.stringify(actual), JSON.stringify(expected), message) }
|
||||
function assert_throws_js(type, run) { try { run() } catch (error) { if (error instanceof type) return; throw new Error("threw " + error.name) } throw new Error("did not throw") }
|
||||
function assert_unreached() { throw new Error("unreachable") }
|
||||
`
|
||||
|
||||
describe("Headers WPT parity (fetch/api/headers)", () => {
|
||||
test("headers-basic.any.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
${testharness}
|
||||
test(function() { new Headers() }, "Create headers from no parameter")
|
||||
test(function() { new Headers(undefined) }, "Create headers from undefined parameter")
|
||||
test(function() { new Headers({}) }, "Create headers from empty object")
|
||||
var parameters = [null, 1]
|
||||
parameters.forEach(function(parameter) {
|
||||
test(function() { assert_throws_js(TypeError, function() { new Headers(parameter) }) }, "Create headers with " + parameter + " should throw")
|
||||
})
|
||||
var headerDict = {"name1": "value1", "name2": "value2", "name3": "value3", "name4": null, "name5": undefined, "name6": 1, "Content-Type": "value4"}
|
||||
var headerSeq = []
|
||||
for (var name in headerDict) headerSeq.push([name, headerDict[name]])
|
||||
test(function() {
|
||||
var headers = new Headers(headerSeq)
|
||||
for (name in headerDict) assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
|
||||
assert_equals(headers.get("length"), null, "init should be treated as a sequence, not as a dictionary")
|
||||
}, "Create headers with sequence")
|
||||
test(function() {
|
||||
var headers = new Headers(headerDict)
|
||||
for (name in headerDict) assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
|
||||
}, "Create headers with record")
|
||||
test(function() {
|
||||
var headers = new Headers(headerDict)
|
||||
var headers2 = new Headers(headers)
|
||||
for (name in headerDict) assert_equals(headers2.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
|
||||
}, "Create headers with existing headers")
|
||||
test(function() {
|
||||
var headers = new Headers()
|
||||
for (name in headerDict) {
|
||||
headers.append(name, headerDict[name])
|
||||
assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
|
||||
}
|
||||
}, "Check append method")
|
||||
test(function() {
|
||||
var headers = new Headers()
|
||||
for (name in headerDict) {
|
||||
headers.set(name, headerDict[name])
|
||||
assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
|
||||
}
|
||||
}, "Check set method")
|
||||
test(function() {
|
||||
var headers = new Headers(headerDict)
|
||||
for (name in headerDict) assert_true(headers.has(name), "headers has name " + name)
|
||||
assert_false(headers.has("nameNotInHeaders"), "headers do not have header: nameNotInHeaders")
|
||||
}, "Check has method")
|
||||
test(function() {
|
||||
var headers = new Headers(headerDict)
|
||||
for (name in headerDict) {
|
||||
assert_true(headers.has(name), "headers have a header: " + name)
|
||||
headers.delete(name)
|
||||
assert_true(!headers.has(name), "headers do not have anymore a header: " + name)
|
||||
}
|
||||
}, "Check delete method")
|
||||
test(function() {
|
||||
var headers = new Headers(headerDict)
|
||||
for (name in headerDict) assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
|
||||
assert_equals(headers.get("nameNotInHeaders"), null, "header: nameNotInHeaders has no value")
|
||||
}, "Check get method")
|
||||
var headerEntriesDict = {"name1": "value1", "Name2": "value2", "name": "value3", "content-Type": "value4", "Content-Typ": "value5", "Content-Types": "value6"}
|
||||
var sortedHeaderDict = {}
|
||||
var headerValues = []
|
||||
var sortedHeaderKeys = Object.keys(headerEntriesDict).map(function(value) {
|
||||
sortedHeaderDict[value.toLowerCase()] = headerEntriesDict[value]
|
||||
headerValues.push(headerEntriesDict[value])
|
||||
return value.toLowerCase()
|
||||
}).sort()
|
||||
test(function() {
|
||||
var headers = new Headers(headerEntriesDict)
|
||||
var actual = headers.keys()
|
||||
sortedHeaderKeys.forEach(function(key) {
|
||||
const entry = actual.next()
|
||||
assert_false(entry.done)
|
||||
assert_equals(entry.value, key)
|
||||
})
|
||||
assert_true(actual.next().done)
|
||||
assert_true(actual.next().done)
|
||||
for (const key of headers.keys()) assert_true(sortedHeaderKeys.indexOf(key) != -1)
|
||||
}, "Check keys method")
|
||||
test(function() {
|
||||
var headers = new Headers(headerEntriesDict)
|
||||
var actual = headers.values()
|
||||
sortedHeaderKeys.forEach(function(key) {
|
||||
const entry = actual.next()
|
||||
assert_false(entry.done)
|
||||
assert_equals(entry.value, sortedHeaderDict[key])
|
||||
})
|
||||
assert_true(actual.next().done)
|
||||
assert_true(actual.next().done)
|
||||
for (const value of headers.values()) assert_true(headerValues.indexOf(value) != -1)
|
||||
}, "Check values method")
|
||||
test(function() {
|
||||
var headers = new Headers(headerEntriesDict)
|
||||
var actual = headers.entries()
|
||||
sortedHeaderKeys.forEach(function(key) {
|
||||
const entry = actual.next()
|
||||
assert_false(entry.done)
|
||||
assert_equals(entry.value[0], key)
|
||||
assert_equals(entry.value[1], sortedHeaderDict[key])
|
||||
})
|
||||
assert_true(actual.next().done)
|
||||
assert_true(actual.next().done)
|
||||
for (const entry of headers.entries()) assert_equals(entry[1], sortedHeaderDict[entry[0]])
|
||||
}, "Check entries method")
|
||||
test(function() {
|
||||
var headers = new Headers(headerEntriesDict)
|
||||
var actual = headers[Symbol.iterator]()
|
||||
sortedHeaderKeys.forEach(function(key) {
|
||||
const entry = actual.next()
|
||||
assert_false(entry.done)
|
||||
assert_equals(entry.value[0], key)
|
||||
assert_equals(entry.value[1], sortedHeaderDict[key])
|
||||
})
|
||||
assert_true(actual.next().done)
|
||||
assert_true(actual.next().done)
|
||||
}, "Check Symbol.iterator method")
|
||||
test(function() {
|
||||
var headers = new Headers(headerEntriesDict)
|
||||
var reference = sortedHeaderKeys[Symbol.iterator]()
|
||||
headers.forEach(function(value, key, container) {
|
||||
assert_equals(headers, container)
|
||||
const entry = reference.next()
|
||||
assert_false(entry.done)
|
||||
assert_equals(key, entry.value)
|
||||
assert_equals(value, sortedHeaderDict[entry.value])
|
||||
})
|
||||
assert_true(reference.next().done)
|
||||
}, "Check forEach method")
|
||||
test(() => {
|
||||
const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0"})
|
||||
const actualKeys = []
|
||||
const actualValues = []
|
||||
for (const [header, value] of headers) {
|
||||
actualKeys.push(header)
|
||||
actualValues.push(value)
|
||||
headers.delete("foo")
|
||||
}
|
||||
assert_array_equals(actualKeys, ["bar", "baz"])
|
||||
assert_array_equals(actualValues, ["0", "1"])
|
||||
}, "Iteration skips elements removed while iterating")
|
||||
test(() => {
|
||||
const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0", "quux": "3"})
|
||||
const actualKeys = []
|
||||
const actualValues = []
|
||||
for (const [header, value] of headers) {
|
||||
actualKeys.push(header)
|
||||
actualValues.push(value)
|
||||
if (header === "baz") headers.delete("bar")
|
||||
}
|
||||
assert_array_equals(actualKeys, ["bar", "baz", "quux"])
|
||||
assert_array_equals(actualValues, ["0", "1", "3"])
|
||||
}, "Removing elements already iterated over causes an element to be skipped during iteration")
|
||||
test(() => {
|
||||
const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0", "quux": "3"})
|
||||
const actualKeys = []
|
||||
const actualValues = []
|
||||
for (const [header, value] of headers) {
|
||||
actualKeys.push(header)
|
||||
actualValues.push(value)
|
||||
if (header === "baz") headers.append("X-yZ", "4")
|
||||
}
|
||||
assert_array_equals(actualKeys, ["bar", "baz", "foo", "quux", "x-yz"])
|
||||
assert_array_equals(actualValues, ["0", "1", "2", "3", "4"])
|
||||
}, "Appending a value pair during iteration causes it to be reached during iteration")
|
||||
test(() => {
|
||||
const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0", "quux": "3"})
|
||||
const actualKeys = []
|
||||
const actualValues = []
|
||||
for (const [header, value] of headers) {
|
||||
actualKeys.push(header)
|
||||
actualValues.push(value)
|
||||
if (header === "baz") headers.append("abc", "-1")
|
||||
}
|
||||
assert_array_equals(actualKeys, ["bar", "baz", "baz", "foo", "quux"])
|
||||
assert_array_equals(actualValues, ["0", "1", "1", "2", "3"])
|
||||
}, "Prepending a value pair before the current element position causes it to be skipped during iteration and adds the current element a second time")
|
||||
return failures
|
||||
`),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test("headers-errors.any.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
${testharness}
|
||||
test(function() { assert_throws_js(TypeError, function() { new Headers([["name"]]) }) }, "Create headers giving an array having one string as init argument")
|
||||
test(function() { assert_throws_js(TypeError, function() { new Headers([["invalid", "invalidValue1", "invalidValue2"]]) }) }, "Create headers giving an array having three strings as init argument")
|
||||
test(function() { assert_throws_js(TypeError, function() { new Headers([["invalid\u0100", "Value1"]]) }) }, "Create headers giving bad header name as init argument")
|
||||
test(function() { assert_throws_js(TypeError, function() { new Headers([["name", "invalidValue\u0100"]]) }) }, "Create headers giving bad header value as init argument")
|
||||
var badNames = ["invalid\u0100", {}]
|
||||
var badValues = ["invalid\u0100"]
|
||||
badNames.forEach(function(name) {
|
||||
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.get(name) }) }, "Check headers get with an invalid name " + name)
|
||||
})
|
||||
badNames.forEach(function(name) {
|
||||
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.delete(name) }) }, "Check headers delete with an invalid name " + name)
|
||||
})
|
||||
badNames.forEach(function(name) {
|
||||
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.has(name) }) }, "Check headers has with an invalid name " + name)
|
||||
})
|
||||
badNames.forEach(function(name) {
|
||||
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.set(name, "Value1") }) }, "Check headers set with an invalid name " + name)
|
||||
})
|
||||
badValues.forEach(function(value) {
|
||||
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.set("name", value) }) }, "Check headers set with an invalid value " + value)
|
||||
})
|
||||
badNames.forEach(function(name) {
|
||||
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.append("invalid\u0100", "Value1") }) }, "Check headers append with an invalid name " + name)
|
||||
})
|
||||
badValues.forEach(function(value) {
|
||||
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.append("name", value) }) }, "Check headers append with an invalid value " + value)
|
||||
})
|
||||
test(function() {
|
||||
var headers = new Headers([["name", "value"]])
|
||||
assert_throws_js(TypeError, function() { headers.forEach() })
|
||||
assert_throws_js(TypeError, function() { headers.forEach(undefined) })
|
||||
assert_throws_js(TypeError, function() { headers.forEach(1) })
|
||||
}, "Headers forEach throws if argument is not callable")
|
||||
test(function() {
|
||||
var headers = new Headers([["name1", "value1"], ["name2", "value2"], ["name3", "value3"]])
|
||||
var counter = 0
|
||||
try {
|
||||
headers.forEach(function(value, name) {
|
||||
counter++
|
||||
if (name == "name2") throw "error"
|
||||
})
|
||||
} catch (e) {
|
||||
assert_equals(counter, 2)
|
||||
assert_equals(e, "error")
|
||||
return
|
||||
}
|
||||
assert_unreached()
|
||||
}, "Headers forEach loop should stop if callback is throwing exception")
|
||||
return failures
|
||||
`),
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"name": "@opencode/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -45,7 +45,8 @@ export declare namespace Source {
|
||||
readonly codec: Schema.Codec<A, Schema.Json>
|
||||
readonly read: Effect.Effect<A | Unavailable | Removed>
|
||||
readonly render: {
|
||||
readonly initial: (current: A) => string
|
||||
/** Omit when the baseline is already visible to the model and only changes carry information. */
|
||||
readonly initial?: (current: A) => string
|
||||
readonly changed: (previous: A, current: A) => string
|
||||
readonly removed?: (previous: A) => string
|
||||
}
|
||||
@@ -88,7 +89,8 @@ export const empty: List = []
|
||||
export function make<A>(source: Source.Definition<A>): List {
|
||||
const decode = Schema.decodeUnknownOption(source.codec)
|
||||
const encode = Schema.encodeSync(source.codec)
|
||||
const initial = (value: A) => requireText(source.key, "initial", source.render.initial(value))
|
||||
const initial = (value: A) =>
|
||||
source.render.initial === undefined ? undefined : requireText(source.key, "initial", source.render.initial(value))
|
||||
const decodeValue = (value: Schema.Json) => Option.getOrUndefined(decode(value))
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -2,7 +2,6 @@ export * as McpInstructions from "./instructions.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Agent } from "../agent.js"
|
||||
import { Permission } from "../permission.js"
|
||||
import { McpTool } from "../tool/mcp.js"
|
||||
import { Mcp } from "./index.js"
|
||||
@@ -55,7 +54,8 @@ const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly load: (agent: Agent.Selection) => Effect.Effect<Instructions.List>
|
||||
/** Lists server instructions reachable under the given ruleset; callers pass the merged agent and Session permissions. */
|
||||
readonly load: (permissions: Permission.Ruleset) => Effect.Effect<Instructions.List>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/McpInstructions") {}
|
||||
@@ -66,9 +66,7 @@ export const layer = Layer.effect(
|
||||
const mcp = yield* Mcp.Service
|
||||
|
||||
return Service.of({
|
||||
load: Effect.fn("McpInstructions.load")(function* (selection) {
|
||||
const agent = selection.info
|
||||
if (!agent) return Instructions.empty
|
||||
load: Effect.fn("McpInstructions.load")(function* (permissions) {
|
||||
const source = (value: ReadonlyArray<Summary> | Instructions.Removed) =>
|
||||
Instructions.make<ReadonlyArray<Summary>>({
|
||||
key: Instructions.Key.make("core/mcp-guidance"),
|
||||
@@ -83,8 +81,8 @@ export const layer = Layer.effect(
|
||||
const [instructions, tools] = yield* Effect.all([mcp.instructions(), mcp.tools()], {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
const canExecute = Permission.evaluate("execute", "*", agent.permissions).effect !== "deny"
|
||||
// Instructions are useful only when this agent can reach at least one server tool.
|
||||
const canExecute = Permission.evaluate("execute", "*", permissions).effect !== "deny"
|
||||
// Instructions are useful only when this Session can reach at least one server tool.
|
||||
const visible = instructions
|
||||
.flatMap((item) => {
|
||||
const owned = tools.filter((tool) => tool.server === item.server)
|
||||
@@ -93,7 +91,7 @@ export const layer = Layer.effect(
|
||||
if (
|
||||
!owned.some(
|
||||
(tool) =>
|
||||
Permission.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny",
|
||||
Permission.evaluate(McpTool.name(tool.server, tool.name), "*", permissions).effect !== "deny",
|
||||
)
|
||||
)
|
||||
return []
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -57,16 +57,19 @@ export const ModelsDevPlugin = define({
|
||||
})
|
||||
}
|
||||
})
|
||||
const apply = (data: readonly ModelsDev.Snapshot[]) => {
|
||||
loaded.data = snapshots(data)
|
||||
return ctx.integration.reload().pipe(Effect.andThen(ctx.provider.reload()))
|
||||
}
|
||||
yield* bus.subscribe(ModelsDev.Event.Refreshed).pipe(
|
||||
Stream.runForEach(() =>
|
||||
modelsDev.get().pipe(
|
||||
Effect.tap((data) => Effect.sync(() => (loaded.data = snapshots(data)))),
|
||||
Effect.andThen(ctx.integration.reload()),
|
||||
Effect.andThen(ctx.provider.reload()),
|
||||
),
|
||||
),
|
||||
Stream.runForEach(() => modelsDev.get().pipe(Effect.flatMap(apply))),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
// A refresh that landed between the initial read and the subscription above published
|
||||
// Refreshed to nobody here. On a cold cache that read served the bundled snapshot, so
|
||||
// re-read now instead of waiting for the next TTL refresh.
|
||||
const latest = yield* modelsDev.get()
|
||||
if (snapshots(latest) !== loaded.data) yield* apply(latest)
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { McpTool } from "../tool/mcp.js"
|
||||
import { ReferenceInstructions } from "../reference/instructions.js"
|
||||
import { SkillInstructions } from "../skill/instructions.js"
|
||||
import { Tool } from "../tool.js"
|
||||
import { ToolInstructions } from "../tool/instructions.js"
|
||||
import { AgentNotFoundError } from "./error.js"
|
||||
import { SessionHistory } from "./history.js"
|
||||
import { SessionProviderContext } from "./provider-context.js"
|
||||
@@ -127,14 +128,16 @@ const layer = Layer.effect(
|
||||
yield* mcpTools.flush
|
||||
const agent = yield* agents.select(session.agent)
|
||||
if (!agent.info) return yield* new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id })
|
||||
// Session permissions narrow discovery the same way they narrow the tool snapshot.
|
||||
const permissions = Permission.merge(agent.info.permissions, session.permissions ?? [])
|
||||
const loaded = yield* Effect.all(
|
||||
{
|
||||
tools: registry.snapshot(Permission.merge(agent.info.permissions, session.permissions ?? [])),
|
||||
tools: registry.snapshot(permissions),
|
||||
builtins: builtins.load(sessionID),
|
||||
discovery: discovery.load(),
|
||||
skills: skillInstructions.load(agent),
|
||||
skills: skillInstructions.load(permissions),
|
||||
references: referenceInstructions.load(),
|
||||
mcp: mcpInstructions.load(agent),
|
||||
mcp: mcpInstructions.load(permissions),
|
||||
entries: entries.load(sessionID),
|
||||
},
|
||||
{ concurrency: "unbounded" },
|
||||
@@ -144,6 +147,7 @@ const layer = Layer.effect(
|
||||
agent: { ...agent, info: agent.info },
|
||||
instructions: Instructions.combine([
|
||||
loaded.builtins,
|
||||
ToolInstructions.make(loaded.tools.definitions.map((definition) => definition.name)),
|
||||
CodeModeInstructions.make(loaded.tools.codeModeCatalog),
|
||||
loaded.discovery,
|
||||
loaded.skills,
|
||||
|
||||
@@ -20,9 +20,10 @@ import { SessionSchema } from "./schema.js"
|
||||
import { webSocketConstructor } from "../effect/app-node-platform.js"
|
||||
|
||||
const ROTATE_AFTER_MS = 55 * 60 * 1000
|
||||
const INBOUND_CAPACITY = 128
|
||||
const CONNECT_TIMEOUT = "10 seconds"
|
||||
const CONNECT_TIMEOUT = "15 seconds"
|
||||
const IDLE_TIMEOUT = "5 minutes"
|
||||
/** Consecutive exchanges lost to the socket before the Session stays on HTTP. */
|
||||
const MAX_STREAM_FAILURES = 5
|
||||
const events = Metric.counter("opencode_session_websocket_events_total", {
|
||||
description: "Session WebSocket lifecycle events",
|
||||
incremental: true,
|
||||
@@ -50,6 +51,7 @@ interface State {
|
||||
readonly lock: Semaphore.Semaphore
|
||||
closed: boolean
|
||||
httpFallback: boolean
|
||||
streamFailures: number
|
||||
channel?: Channel
|
||||
}
|
||||
|
||||
@@ -126,7 +128,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
const state = (sessionID: SessionSchema.ID) => {
|
||||
const current = states.get(sessionID)
|
||||
if (current) return current
|
||||
const created = { lock: Semaphore.makeUnsafe(1), closed: false, httpFallback: false }
|
||||
const created = { lock: Semaphore.makeUnsafe(1), closed: false, httpFallback: false, streamFailures: 0 }
|
||||
states.set(sessionID, created)
|
||||
return created
|
||||
}
|
||||
@@ -168,15 +170,27 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
code: error.reason._tag === "Transport" ? error.reason.code : error.reason._tag,
|
||||
active: channel.active !== undefined,
|
||||
})
|
||||
if (channel.active) Queue.failCauseUnsafe(channel.active.queue, Cause.fail(error))
|
||||
yield* metric(
|
||||
error.reason._tag === "Transport" && error.reason.code === "queue-overflow"
|
||||
? "queue_overflow"
|
||||
: "protocol_failure",
|
||||
)
|
||||
if (channel.active) {
|
||||
Queue.failCauseUnsafe(channel.active.queue, Cause.fail(error))
|
||||
yield* streamFailure(owner)
|
||||
}
|
||||
yield* metric("protocol_failure")
|
||||
yield* channel.connection.close
|
||||
})
|
||||
|
||||
// A socket that keeps dying mid-exchange costs a retry every step; after enough consecutive
|
||||
// losses the Session stays on HTTP.
|
||||
const streamFailure = Effect.fn("SessionModelTransport.streamFailure")(function* (owner: State) {
|
||||
owner.streamFailures++
|
||||
if (owner.streamFailures < MAX_STREAM_FAILURES) return
|
||||
owner.httpFallback = true
|
||||
yield* Effect.logWarning("session websocket failed repeatedly; using http", {
|
||||
sessionTransport: "websocket",
|
||||
failures: owner.streamFailures,
|
||||
})
|
||||
yield* metric("fallback", { reason: "stream_failures" })
|
||||
})
|
||||
|
||||
const open = Effect.fn("SessionModelTransport.open")(function* (
|
||||
owner: State,
|
||||
exchange: WebSocketChannelExchange,
|
||||
@@ -235,14 +249,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
code: "message",
|
||||
phase: "receive",
|
||||
})
|
||||
if (Queue.offerUnsafe(active.queue, message)) return undefined
|
||||
return yield* transportError("Session WebSocket inbound queue overflow", {
|
||||
url: exchange.connect.url,
|
||||
operation: "read",
|
||||
code: "queue-overflow",
|
||||
phase: "receive",
|
||||
delivery: "accepted",
|
||||
})
|
||||
Queue.offerUnsafe(active.queue, message)
|
||||
}),
|
||||
),
|
||||
Effect.catch((error) =>
|
||||
@@ -255,9 +262,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
phase:
|
||||
error.reason._tag === "Transport" && error.reason.phase === "close" ? "close" : "receive",
|
||||
delivery:
|
||||
channel.active?.delivery === "provider-observed" ||
|
||||
channel.active?.delivery === "terminal" ||
|
||||
(error.reason._tag === "Transport" && error.reason.code === "queue-overflow")
|
||||
channel.active?.delivery === "provider-observed" || channel.active?.delivery === "terminal"
|
||||
? "accepted"
|
||||
: error.reason._tag === "Transport" && error.reason.code === "1009"
|
||||
? "rejected"
|
||||
@@ -370,7 +375,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
mode: create.mode,
|
||||
})
|
||||
const active: Active = {
|
||||
queue: yield* Queue.bounded<string, AIError>(INBOUND_CAPACITY),
|
||||
queue: yield* Queue.unbounded<string, AIError>(),
|
||||
delivery: "send-attempted",
|
||||
}
|
||||
channel.active = active
|
||||
@@ -395,6 +400,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
return fallback(exchange)
|
||||
}
|
||||
yield* metric("ambiguous_delivery")
|
||||
yield* streamFailure(owner)
|
||||
return yield* annotate(failure, { phase: "send", delivery: "ambiguous" })
|
||||
}
|
||||
yield* metric("send")
|
||||
@@ -435,6 +441,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
const pending = yield* Queue.size(active.queue)
|
||||
yield* Queue.shutdown(active.queue)
|
||||
if (terminal && pending === 0) {
|
||||
owner.streamFailures = 0
|
||||
yield* metric("terminal", { type: terminal.type })
|
||||
if (terminal.type === "rejected") yield* metric("rejection", { recovery: terminal.recovery })
|
||||
// The Codex backend stops serving a connection after any error frame: the next request is
|
||||
|
||||
@@ -4,7 +4,6 @@ import { makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
import type { FSUtil } from "@opencode/util/fs-util"
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Types } from "effect"
|
||||
import type { Agent } from "@opencode/schema/agent"
|
||||
import { Skill } from "@opencode/schema/skill"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Permission } from "./permission.js"
|
||||
@@ -31,8 +30,8 @@ export type Name = Skill.Name
|
||||
|
||||
export { Event } from "@opencode/schema/skill"
|
||||
|
||||
export const available = (skills: ReadonlyArray<Info>, agent: Agent.Info) =>
|
||||
skills.filter((skill) => Permission.evaluate("skill", skill.id, agent.permissions).effect !== "deny")
|
||||
export const available = (skills: ReadonlyArray<Info>, permissions: Permission.Ruleset) =>
|
||||
skills.filter((skill) => Permission.evaluate("skill", skill.id, permissions).effect !== "deny")
|
||||
|
||||
export const toModelOutput = (skill: Info, files: ReadonlyArray<string>) => {
|
||||
const directory = path.dirname(skill.path)
|
||||
|
||||
@@ -2,7 +2,7 @@ export * as SkillInstructions from "./instructions.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Agent } from "../agent.js"
|
||||
import { Permission } from "../permission.js"
|
||||
import { Skill } from "../skill.js"
|
||||
import { Instructions } from "../instructions/index.js"
|
||||
|
||||
@@ -58,7 +58,8 @@ const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly load: (agent: Agent.Selection) => Effect.Effect<Instructions.List>
|
||||
/** Lists skills the given ruleset does not deny; callers pass the merged agent and Session permissions. */
|
||||
readonly load: (permissions: Permission.Ruleset) => Effect.Effect<Instructions.List>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SkillInstructions") {}
|
||||
@@ -69,10 +70,8 @@ const layer = Layer.effect(
|
||||
const skills = yield* Skill.Service
|
||||
|
||||
return Service.of({
|
||||
load: Effect.fn("SkillInstructions.load")(function* (selection) {
|
||||
const agent = selection.info
|
||||
if (!agent) return Instructions.empty
|
||||
const available = Skill.available(yield* skills.list(), agent)
|
||||
load: Effect.fn("SkillInstructions.load")(function* (permissions) {
|
||||
const available = Skill.available(yield* skills.list(), permissions)
|
||||
.flatMap((skill) =>
|
||||
skill.description === undefined || skill.autoinvoke === false
|
||||
? []
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
export * as ToolInstructions from "./instructions.js"
|
||||
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Instructions } from "../instructions/index.js"
|
||||
|
||||
const Names = Schema.Array(Schema.String)
|
||||
type Names = typeof Names.Type
|
||||
|
||||
const list = (names: ReadonlyArray<string>) => names.map((name) => `\`${name}\``).join(", ")
|
||||
|
||||
export function update(previous: Names, current: Names) {
|
||||
const added = current.filter((name) => !previous.includes(name))
|
||||
const removed = previous.filter((name) => !current.includes(name))
|
||||
return [
|
||||
"The available tools have changed.",
|
||||
...(added.length > 0 ? [`New tools are available in addition to those previously provided: ${list(added)}.`] : []),
|
||||
...(removed.length > 0
|
||||
? [`The following tools are no longer available and must not be called: ${list(removed)}.`]
|
||||
: []),
|
||||
].join("\n\n")
|
||||
}
|
||||
|
||||
const key = Instructions.Key.make("core/tools")
|
||||
const codec = Schema.toCodecJson(Names)
|
||||
|
||||
/**
|
||||
* Tracks the top-level tool names the model has been shown. The request's own
|
||||
* tool list is the baseline, so nothing renders until that set changes.
|
||||
*/
|
||||
export const make = (names: ReadonlyArray<string>): Instructions.List =>
|
||||
Instructions.make({
|
||||
key,
|
||||
codec,
|
||||
read: Effect.succeed(Array.from(new Set(names)).sort()),
|
||||
render: { changed: update },
|
||||
})
|
||||
@@ -1,23 +1,14 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Agent } from "@opencode/core/agent"
|
||||
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
||||
import { Mcp } from "@opencode/core/mcp/index"
|
||||
import { McpInstructions } from "@opencode/core/mcp/instructions"
|
||||
import { Permission } from "@opencode/core/permission"
|
||||
import { McpTool } from "@opencode/core/tool/mcp"
|
||||
import { it } from "./lib/effect"
|
||||
import { readInitial, readUpdate } from "./lib/instructions"
|
||||
|
||||
const build = Agent.ID.make("build")
|
||||
|
||||
const selection = (permissions: Permission.Ruleset = []) => {
|
||||
const info = Agent.Info.make({ ...Agent.Info.default(build), permissions })
|
||||
return { id: info.id, info }
|
||||
}
|
||||
|
||||
const instructions = (server: string, text: string) =>
|
||||
({ server: Mcp.ServerName.make(server), instructions: text } satisfies Mcp.ServerInstructions)
|
||||
({ server: Mcp.ServerName.make(server), instructions: text }) satisfies Mcp.ServerInstructions
|
||||
|
||||
const schema = { type: "object" as const }
|
||||
const tool = (server: string, name = "search") =>
|
||||
@@ -38,12 +29,10 @@ describe("McpInstructions", () => {
|
||||
Effect.gen(function* () {
|
||||
const service = yield* McpInstructions.Service
|
||||
const generation = yield* service
|
||||
.load(
|
||||
selection([
|
||||
{ action: McpTool.name("alpha", "restricted"), resource: "*", effect: "deny" },
|
||||
{ action: McpTool.name("hidden", "search"), resource: "*", effect: "deny" },
|
||||
]),
|
||||
)
|
||||
.load([
|
||||
{ action: McpTool.name("alpha", "restricted"), resource: "*", effect: "deny" },
|
||||
{ action: McpTool.name("hidden", "search"), resource: "*", effect: "deny" },
|
||||
])
|
||||
.pipe(Effect.flatMap(readInitial))
|
||||
|
||||
expect(generation.text).toBe(
|
||||
@@ -80,7 +69,7 @@ describe("McpInstructions", () => {
|
||||
Effect.gen(function* () {
|
||||
const service = yield* McpInstructions.Service
|
||||
const generation = yield* service
|
||||
.load(selection([{ action: "execute", resource: "*", effect: "deny" }]))
|
||||
.load([{ action: "execute", resource: "*", effect: "deny" }])
|
||||
.pipe(Effect.flatMap(readInitial))
|
||||
|
||||
expect(generation.text).toBe("")
|
||||
@@ -98,7 +87,7 @@ describe("McpInstructions", () => {
|
||||
Effect.gen(function* () {
|
||||
const service = yield* McpInstructions.Service
|
||||
const generation = yield* service
|
||||
.load(selection([{ action: "execute", resource: "*", effect: "deny" }]))
|
||||
.load([{ action: "execute", resource: "*", effect: "deny" }])
|
||||
.pipe(Effect.flatMap(readInitial))
|
||||
|
||||
expect(generation.text).toBe(
|
||||
@@ -114,7 +103,14 @@ describe("McpInstructions", () => {
|
||||
Effect.provide(
|
||||
layer(
|
||||
() => [instructions("alpha", "Alpha instructions")],
|
||||
() => [({ server: Mcp.ServerName.make("alpha"), name: "search", inputSchema: schema, codemode: false }) satisfies Mcp.Tool],
|
||||
() => [
|
||||
{
|
||||
server: Mcp.ServerName.make("alpha"),
|
||||
name: "search",
|
||||
inputSchema: schema,
|
||||
codemode: false,
|
||||
} satisfies Mcp.Tool,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -124,10 +120,10 @@ describe("McpInstructions", () => {
|
||||
let tools: Mcp.Tool[] = [tool("alpha")]
|
||||
return Effect.gen(function* () {
|
||||
const service = yield* McpInstructions.Service
|
||||
const initialized = yield* service.load(selection()).pipe(Effect.flatMap(readInitial))
|
||||
const initialized = yield* service.load([]).pipe(Effect.flatMap(readInitial))
|
||||
|
||||
tools = [{ ...tool("alpha"), codemode: false }]
|
||||
const changed = yield* readUpdate(yield* service.load(selection()), initialized)
|
||||
const changed = yield* readUpdate(yield* service.load([]), initialized)
|
||||
expect(changed.text).toBe(
|
||||
[
|
||||
"The available MCP server instructions have changed. This list supersedes the previous one.",
|
||||
@@ -153,10 +149,10 @@ describe("McpInstructions", () => {
|
||||
const tools = [tool("alpha"), tool("beta")]
|
||||
return Effect.gen(function* () {
|
||||
const service = yield* McpInstructions.Service
|
||||
const initialized = yield* service.load(selection()).pipe(Effect.flatMap(readInitial))
|
||||
const initialized = yield* service.load([]).pipe(Effect.flatMap(readInitial))
|
||||
|
||||
catalog = [instructions("alpha", "Alpha instructions"), instructions("beta", "Beta instructions")]
|
||||
const added = yield* readUpdate(yield* service.load(selection()), initialized)
|
||||
const added = yield* readUpdate(yield* service.load([]), initialized)
|
||||
expect(added.text).toBe(
|
||||
[
|
||||
"New MCP server instructions are available in addition to those previously listed:",
|
||||
@@ -168,7 +164,7 @@ describe("McpInstructions", () => {
|
||||
)
|
||||
|
||||
catalog = [instructions("alpha", "Updated alpha"), instructions("beta", "Beta instructions")]
|
||||
const changed = yield* readUpdate(yield* service.load(selection()), added)
|
||||
const changed = yield* readUpdate(yield* service.load([]), added)
|
||||
expect(changed.text).toBe(
|
||||
[
|
||||
"The available MCP server instructions have changed. This list supersedes the previous one.",
|
||||
@@ -186,11 +182,11 @@ describe("McpInstructions", () => {
|
||||
)
|
||||
|
||||
catalog = [instructions("beta", "Beta instructions")]
|
||||
const removed = yield* readUpdate(yield* service.load(selection()), changed)
|
||||
const removed = yield* readUpdate(yield* service.load([]), changed)
|
||||
expect(removed.text).toBe("Instructions for the following MCP servers are no longer available: alpha.")
|
||||
|
||||
catalog = []
|
||||
expect((yield* readUpdate(yield* service.load(selection()), removed)).text).toBe(
|
||||
expect((yield* readUpdate(yield* service.load([]), removed)).text).toBe(
|
||||
"MCP server instructions are no longer available.",
|
||||
)
|
||||
}).pipe(
|
||||
|
||||
@@ -290,6 +290,37 @@ describe("ModelsDevPlugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
isolated.effect("adopts a refresh that completes between its initial read and its subscription", () =>
|
||||
Effect.gen(function* () {
|
||||
const bundled = richSnapshot("Acme Bundled")
|
||||
const fresh = richSnapshot("Acme Fresh")
|
||||
const current = { snapshot: bundled.snapshot }
|
||||
const location = yield* owner
|
||||
// Cold cache: the first read serves the bundled snapshot, and the boot-time
|
||||
// ModelsDev.refresh() lands right after it, before the plugin subscribes.
|
||||
const source = ModelsDev.Service.of({
|
||||
get: () =>
|
||||
Effect.gen(function* () {
|
||||
const data = current.snapshot
|
||||
if (data !== bundled.snapshot) return data
|
||||
current.snapshot = fresh.snapshot
|
||||
yield* location.bus.publish(ModelsDev.Event.Refreshed, {})
|
||||
return data
|
||||
}),
|
||||
refresh: () => Effect.void,
|
||||
})
|
||||
yield* ModelsDevPlugin.effect(location.host).pipe(
|
||||
Effect.provideService(ModelsDev.Service, source),
|
||||
Effect.provideContext(location.context),
|
||||
)
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* TestClock.adjust("500 millis")
|
||||
|
||||
expect(required(yield* location.providers.get(bundled.providerID)).name).toBe("Acme Fresh")
|
||||
}),
|
||||
)
|
||||
|
||||
real.effect("keeps the retained definition unchanged across model replay", () =>
|
||||
Effect.gen(function* () {
|
||||
const providers = yield* Provider.Service
|
||||
|
||||
@@ -593,7 +593,7 @@ describe("SessionModelTransport", () => {
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
)
|
||||
yield* Effect.yieldNow
|
||||
yield* TestClock.adjust("10 seconds")
|
||||
yield* TestClock.adjust("15 seconds")
|
||||
expect(yield* Fiber.join(running)).toEqual(["fallback:slow"])
|
||||
}),
|
||||
)
|
||||
@@ -731,6 +731,40 @@ describe("SessionModelTransport", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("keeps the Session on HTTP after repeated mid-stream socket losses", async () => {
|
||||
let opens = 0
|
||||
const connector: WebSocketConnector = {
|
||||
open: () =>
|
||||
Effect.gen(function* () {
|
||||
opens++
|
||||
const messages = yield* Queue.unbounded<string | Uint8Array, AIError>()
|
||||
return {
|
||||
sendText: () =>
|
||||
Effect.sync(() => {
|
||||
Queue.failCauseUnsafe(messages, Cause.fail(error("socket dropped")))
|
||||
}),
|
||||
messages: Stream.fromQueue(messages),
|
||||
close: Queue.shutdown(messages).pipe(Effect.asVoid),
|
||||
}
|
||||
}),
|
||||
}
|
||||
|
||||
await run(
|
||||
connector,
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const executor = transport.bind(session)
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
const result = yield* Effect.result(collect(executor, exchange(`attempt-${attempt}`)))
|
||||
expect(result._tag).toBe("Failure")
|
||||
}
|
||||
expect(opens).toBe(5)
|
||||
expect(yield* collect(executor, exchange("sixth"))).toEqual(["fallback:sixth"])
|
||||
expect(opens).toBe(5)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("falls back to HTTP after close code 1009 and keeps the Session on HTTP", async () => {
|
||||
const messages = queue<string | Uint8Array, AIError>()
|
||||
let opened = 0
|
||||
@@ -915,24 +949,22 @@ describe("SessionModelTransport", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("poisons instead of dropping data when the inbound queue overflows", async () => {
|
||||
test("buffers a synchronous burst of frames larger than any fixed capacity", async () => {
|
||||
// Bun dispatches every frame in a read buffer in one tick; a large tool call streams thousands
|
||||
// of small argument deltas, so the exchange must absorb the whole burst before it can consume.
|
||||
const burst = 1500
|
||||
const messages = queue<string | Uint8Array, AIError>()
|
||||
const poisoned = Deferred.makeUnsafe<void>()
|
||||
let closed = 0
|
||||
const connector: WebSocketConnector = {
|
||||
open: () =>
|
||||
Effect.succeed({
|
||||
sendText: () =>
|
||||
// Hold consumption at the send boundary until the reader fills and poisons the inbound queue.
|
||||
Effect.sync(() => {
|
||||
for (let index = 0; index <= 129; index++) Queue.offerUnsafe(messages, `frame:${index}`)
|
||||
}).pipe(Effect.andThen(Deferred.await(poisoned))),
|
||||
messages: Stream.fromQueue(messages).pipe(Stream.tap(() => Effect.yieldNow)),
|
||||
close: Effect.sync(() => closed++).pipe(
|
||||
Effect.andThen(Deferred.succeed(poisoned, undefined)),
|
||||
Effect.andThen(Queue.shutdown(messages)),
|
||||
Effect.asVoid,
|
||||
),
|
||||
for (let index = 0; index < burst; index++) Queue.offerUnsafe(messages, `frame:${index}`)
|
||||
Queue.offerUnsafe(messages, "completed")
|
||||
}),
|
||||
messages: Stream.fromQueue(messages),
|
||||
close: Effect.sync(() => closed++).pipe(Effect.andThen(Queue.shutdown(messages)), Effect.asVoid),
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -941,20 +973,21 @@ describe("SessionModelTransport", () => {
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const item = exchange("first")
|
||||
const result = yield* Effect.result(
|
||||
collect(transport.bind(session), {
|
||||
...item,
|
||||
driver: {
|
||||
create: item.driver.create,
|
||||
observe: (_create, frame) => Effect.succeed({ type: "frame" as const, frame }),
|
||||
},
|
||||
}),
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
_tag: "Failure",
|
||||
failure: { reason: { _tag: "Transport", code: "queue-overflow", delivery: "accepted" } },
|
||||
const frames = yield* collect(transport.bind(session), {
|
||||
...item,
|
||||
driver: {
|
||||
create: item.driver.create,
|
||||
observe: (_create, frame) =>
|
||||
Effect.succeed(
|
||||
frame === "completed" ? { type: "completed" as const, frame } : { type: "frame" as const, frame },
|
||||
),
|
||||
},
|
||||
})
|
||||
expect(closed).toBe(1)
|
||||
expect(frames).toHaveLength(burst + 1)
|
||||
expect(frames.slice(0, 3)).toEqual(["frame:0", "frame:1", "frame:2"])
|
||||
expect(frames.at(-2)).toBe(`frame:${burst - 1}`)
|
||||
expect(frames.at(-1)).toBe("completed")
|
||||
expect(closed).toBe(0)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -362,21 +362,27 @@ const layer = Layer.unwrap(
|
||||
load: () => Effect.succeed(Instructions.empty),
|
||||
})
|
||||
const skillInstructions = Layer.mock(SkillInstructions.Service, {
|
||||
load: (agent) =>
|
||||
Effect.succeed(
|
||||
state.skillBaselines.has(agent.id)
|
||||
? Instructions.make({
|
||||
load: (permissions) => {
|
||||
// `setup` tags each agent's ruleset with a `skill` rule naming that agent.
|
||||
const baseline = permissions
|
||||
.filter((rule) => rule.action === "skill")
|
||||
.map((rule) => state.skillBaselines.get(Agent.ID.make(rule.resource)))
|
||||
.findLast((text) => text !== undefined)
|
||||
return Effect.succeed(
|
||||
baseline === undefined
|
||||
? Instructions.empty
|
||||
: Instructions.make({
|
||||
key: Instructions.Key.make("test/skill-guidance"),
|
||||
codec: Schema.toCodecJson(Schema.String),
|
||||
read: Effect.succeed(state.skillBaselines.get(agent.id)!),
|
||||
read: Effect.succeed(baseline),
|
||||
render: {
|
||||
initial: String,
|
||||
changed: (_previous, current) => current,
|
||||
removed: () => "Skill guidance removed",
|
||||
},
|
||||
})
|
||||
: Instructions.empty,
|
||||
),
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
const referenceInstructions = Layer.mock(ReferenceInstructions.Service, {
|
||||
load: () => Effect.succeed(Instructions.empty),
|
||||
@@ -526,11 +532,17 @@ const setup = Effect.gen(function* () {
|
||||
})
|
||||
yield* IdentityPlugin.Plugin.effect(pluginHost)
|
||||
yield* NativeCompactionPlugin.Plugin.effect(pluginHost)
|
||||
yield* agents.transform((editor) =>
|
||||
yield* agents.transform((editor) => {
|
||||
editor.update(Agent.ID.make("build"), (agent) => {
|
||||
agent.mode = "primary"
|
||||
}),
|
||||
)
|
||||
})
|
||||
// Skill instructions receive only a ruleset, so tag each agent with a rule naming itself for the mock.
|
||||
for (const id of ["build", "reviewer"]) {
|
||||
editor.update(Agent.ID.make(id), (agent) => {
|
||||
agent.permissions.push({ action: "skill", resource: id, effect: "allow" })
|
||||
})
|
||||
}
|
||||
})
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
|
||||
@@ -119,7 +119,7 @@ describe("Skill", () => {
|
||||
}),
|
||||
)
|
||||
const agent = yield* agents.get(Agent.ID.make("reviewer"))
|
||||
expect(Skill.available([info("deploy", "Deploy")], agent!)).toEqual([])
|
||||
expect(Skill.available([info("deploy", "Deploy")], agent!.permissions)).toEqual([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -53,7 +53,7 @@ describe("SkillInstructions", () => {
|
||||
let skills = [hidden, denied, manual, effect]
|
||||
return Effect.gen(function* () {
|
||||
const instructions = yield* SkillInstructions.Service
|
||||
const initialized = yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))
|
||||
const initialized = yield* instructions.load(agent.permissions).pipe(Effect.flatMap(readInitial))
|
||||
|
||||
expect(initialized.text).toBe(
|
||||
[
|
||||
@@ -74,7 +74,7 @@ describe("SkillInstructions", () => {
|
||||
skills = []
|
||||
expect(
|
||||
yield* instructions
|
||||
.load({ id: agent.id, info: agent })
|
||||
.load(agent.permissions)
|
||||
.pipe(Effect.flatMap((context) => readUpdate(context, initialized))),
|
||||
).toMatchObject({ text: "Skill guidance is no longer available. Do not use any previously listed skill." })
|
||||
}).pipe(Effect.provide(layer(() => skills)))
|
||||
@@ -92,11 +92,11 @@ describe("SkillInstructions", () => {
|
||||
let skills = [effect]
|
||||
return Effect.gen(function* () {
|
||||
const instructions = yield* SkillInstructions.Service
|
||||
const initialized = yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))
|
||||
const initialized = yield* instructions.load(agent.permissions).pipe(Effect.flatMap(readInitial))
|
||||
|
||||
skills = [effect, debugging]
|
||||
const added = yield* instructions
|
||||
.load({ id: agent.id, info: agent })
|
||||
.load(agent.permissions)
|
||||
.pipe(Effect.flatMap((context) => readUpdate(context, initialized)))
|
||||
expect(added.text).toBe(
|
||||
[
|
||||
@@ -111,7 +111,7 @@ describe("SkillInstructions", () => {
|
||||
|
||||
skills = [debugging]
|
||||
const removed = yield* instructions
|
||||
.load({ id: agent.id, info: agent })
|
||||
.load(agent.permissions)
|
||||
.pipe(Effect.flatMap((context) => readUpdate(context, added)))
|
||||
expect(removed.text).toBe("The following skill IDs are no longer available and must not be used: effect.")
|
||||
}).pipe(Effect.provide(layer(() => skills)))
|
||||
@@ -122,12 +122,12 @@ describe("SkillInstructions", () => {
|
||||
let skills = [effect]
|
||||
return Effect.gen(function* () {
|
||||
const instructions = yield* SkillInstructions.Service
|
||||
const initialized = yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))
|
||||
const initialized = yield* instructions.load(agent.permissions).pipe(Effect.flatMap(readInitial))
|
||||
|
||||
skills = [Skill.Info.make({ ...effect, description: "Build applications with Effect v4" })]
|
||||
expect(
|
||||
yield* instructions
|
||||
.load({ id: agent.id, info: agent })
|
||||
.load(agent.permissions)
|
||||
.pipe(Effect.flatMap((context) => readUpdate(context, initialized))),
|
||||
).toMatchObject({
|
||||
text: expect.stringContaining(
|
||||
@@ -144,7 +144,7 @@ describe("SkillInstructions", () => {
|
||||
})
|
||||
return Effect.gen(function* () {
|
||||
const instructions = yield* SkillInstructions.Service
|
||||
expect((yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))).text).toBe("")
|
||||
expect((yield* instructions.load(agent.permissions).pipe(Effect.flatMap(readInitial))).text).toBe("")
|
||||
}).pipe(Effect.provide(layer(() => [effect])))
|
||||
})
|
||||
|
||||
@@ -158,7 +158,7 @@ describe("SkillInstructions", () => {
|
||||
})
|
||||
return Effect.gen(function* () {
|
||||
const instructions = yield* SkillInstructions.Service
|
||||
expect((yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))).text).toBe("")
|
||||
expect((yield* instructions.load(agent.permissions).pipe(Effect.flatMap(readInitial))).text).toBe("")
|
||||
}).pipe(Effect.provide(layer(() => [effect])))
|
||||
})
|
||||
|
||||
@@ -173,7 +173,7 @@ describe("SkillInstructions", () => {
|
||||
return Effect.gen(function* () {
|
||||
const instructions = yield* SkillInstructions.Service
|
||||
expect(
|
||||
(yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))).text,
|
||||
(yield* instructions.load(agent.permissions).pipe(Effect.flatMap(readInitial))).text,
|
||||
).toContain("<name>Effect</name>")
|
||||
}).pipe(Effect.provide(layer(() => [effect])))
|
||||
})
|
||||
@@ -189,7 +189,7 @@ describe("SkillInstructions", () => {
|
||||
})
|
||||
return Effect.gen(function* () {
|
||||
const instructions = yield* SkillInstructions.Service
|
||||
expect((yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))).text).toBe("")
|
||||
expect((yield* instructions.load(agent.permissions).pipe(Effect.flatMap(readInitial))).text).toBe("")
|
||||
}).pipe(Effect.provide(layer(() => [effect])))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { ToolInstructions } from "@opencode/core/tool/instructions"
|
||||
import { Effect } from "effect"
|
||||
import { it } from "../lib/effect"
|
||||
import { readInitial, readUpdate } from "../lib/instructions"
|
||||
|
||||
describe("ToolInstructions", () => {
|
||||
it.effect("renders nothing for the baseline and announces only the delta afterwards", () =>
|
||||
Effect.gen(function* () {
|
||||
const initialized = yield* readInitial(ToolInstructions.make(["shell", "read", "edit"]))
|
||||
expect(initialized.text).toBe("")
|
||||
expect(initialized.values["core/tools"]).toEqual(["edit", "read", "shell"])
|
||||
|
||||
const unchanged = yield* readUpdate(ToolInstructions.make(["edit", "shell", "read", "read"]), initialized)
|
||||
expect(unchanged.text).toBe("")
|
||||
|
||||
const changed = yield* readUpdate(ToolInstructions.make(["edit", "read", "write", "glob"]), initialized)
|
||||
expect(changed.text).toBe(
|
||||
[
|
||||
"The available tools have changed.",
|
||||
"New tools are available in addition to those previously provided: `glob`, `write`.",
|
||||
"The following tools are no longer available and must not be called: `shell`.",
|
||||
].join("\n\n"),
|
||||
)
|
||||
|
||||
const restored = yield* readUpdate(ToolInstructions.make(["edit", "read", "shell", "write", "glob"]), changed)
|
||||
expect(restored.text).toBe(
|
||||
[
|
||||
"The available tools have changed.",
|
||||
"New tools are available in addition to those previously provided: `shell`.",
|
||||
].join("\n\n"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("announces transitions to and from an empty tool set", () =>
|
||||
Effect.gen(function* () {
|
||||
const initialized = yield* readInitial(ToolInstructions.make([]))
|
||||
expect(initialized.text).toBe("")
|
||||
|
||||
const added = yield* readUpdate(ToolInstructions.make(["read"]), initialized)
|
||||
expect(added.text).toBe(
|
||||
[
|
||||
"The available tools have changed.",
|
||||
"New tools are available in addition to those previously provided: `read`.",
|
||||
].join("\n\n"),
|
||||
)
|
||||
|
||||
const emptied = yield* readUpdate(ToolInstructions.make([]), added)
|
||||
expect(emptied.text).toBe(
|
||||
[
|
||||
"The available tools have changed.",
|
||||
"The following tools are no longer available and must not be called: `read`.",
|
||||
].join("\n\n"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode/desktop",
|
||||
"private": true,
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//
|
||||
// bun run bench:startup -- [--exe <path>] [--compare <path>] [--runs 5] [--warmup 1] [--service warm|cold]
|
||||
// [--fresh] [--offline] [--seed <userData dir>] [--profile-main] [--profile-renderer]
|
||||
// [--trace] [--out <dir>] [--home <dir>]
|
||||
// [--trace] [--out <dir>] [--home <dir>] [--window-at x,y]
|
||||
//
|
||||
// The app runs in an isolated home directory (its own %APPDATA%, XDG dirs, OpenCode DB, config and
|
||||
// service registration) with the developer's OPENCODE_* / OTEL_* environment stripped, so it never
|
||||
@@ -21,7 +21,15 @@
|
||||
// main-process CPU profile from the first statement (via --inspect-brk) on the first run,
|
||||
// `--profile-renderer` records the renderer main thread from the moment its debug target appears,
|
||||
// and `--trace` records Chromium's startup trace on the last run. Raw samples are written as JSON.
|
||||
import { spawn } from "node:child_process"
|
||||
//
|
||||
// What is on screen is sampled from the screen itself (Windows): a helper pins the window topmost
|
||||
// without activating it the moment it exists, then records when the window's pixels first differ
|
||||
// from the background colour (`screenPainted`) and when they stop changing (`screenSettled`).
|
||||
// Renderer paint timing alone is not enough: Chromium stops painting an occluded window, and a
|
||||
// splash or a fade reads as "painted" long before the interface is on screen. `--window-at` puts
|
||||
// the window somewhere the developer's foreground window does not cover. BENCH_SCREEN_DUMP=<dir>
|
||||
// also saves every sample as PNG, BENCH_EXTRA_ARGS passes extra Chromium switches to the app.
|
||||
import { execFileSync, spawn } from "node:child_process"
|
||||
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"
|
||||
import { createServer } from "node:net"
|
||||
import { tmpdir } from "node:os"
|
||||
@@ -45,6 +53,9 @@ const args = parseArgs({
|
||||
"profile-renderer": { type: "boolean", default: false },
|
||||
trace: { type: "boolean", default: false },
|
||||
"settle-ms": { type: "string", default: "1500" },
|
||||
// Restore the bench window at "x,y" (and treat that display as trusted), for example on a display
|
||||
// that the developer's foreground window does not cover; Chromium stops painting an occluded window.
|
||||
"window-at": { type: "string" },
|
||||
},
|
||||
allowPositionals: true,
|
||||
})
|
||||
@@ -112,10 +123,12 @@ const probe = `(() => ({
|
||||
origin: performance.timeOrigin,
|
||||
firstPaint: performance.getEntriesByType('paint').find((e) => e.name === 'first-paint')?.startTime,
|
||||
domInteractive: performance.getEntriesByType('navigation')[0]?.domInteractive,
|
||||
shell: !!document.querySelector('[data-titlebar-tab-link], [data-action="vertical-tabs-home"]'),
|
||||
editor: !!document.querySelector('[data-component="composer-editor"][contenteditable="true"]'),
|
||||
rows: document.querySelectorAll('[data-timeline-row]').length,
|
||||
home: !!document.querySelector('[data-action="home-new-session"], [data-action="home-add-project-row"]'),
|
||||
prepaint: !!document.getElementById('oc-prepaint'),
|
||||
visible: document.visibilityState === 'visible',
|
||||
shell: !!document.querySelector('#root [data-titlebar-tab-link], #root [data-action="vertical-tabs-home"]'),
|
||||
editor: !!document.querySelector('#root [data-component="composer-editor"][contenteditable="true"]'),
|
||||
rows: document.querySelectorAll('#root [data-timeline-row]').length,
|
||||
home: !!document.querySelector('#root [data-action="home-new-session"], #root [data-action="home-add-project-row"]'),
|
||||
url: location.pathname + location.search,
|
||||
}))()`
|
||||
// Main-process bootstrap timing, read after the run: when the process was created, when Node
|
||||
@@ -237,6 +250,8 @@ type Probe = {
|
||||
origin: number
|
||||
firstPaint?: number
|
||||
domInteractive?: number
|
||||
prepaint: boolean
|
||||
visible: boolean
|
||||
shell: boolean
|
||||
editor: boolean
|
||||
rows: number
|
||||
@@ -273,6 +288,7 @@ async function launch(build: { label: string; exe: string }, run: number): Promi
|
||||
const trace = args.values.trace && run === runs
|
||||
const tracePath = join(outDir, `startup-trace-${Date.now()}.json`)
|
||||
const launchArgs = [
|
||||
...(process.env.BENCH_EXTRA_ARGS?.split(" ").filter(Boolean) ?? []),
|
||||
`--remote-debugging-port=${cdpPort}`,
|
||||
profile ? `--inspect-brk=${inspectPort}` : `--inspect=${inspectPort}`,
|
||||
...(trace
|
||||
@@ -284,10 +300,12 @@ async function launch(build: { label: string; exe: string }, run: number): Promi
|
||||
]
|
||||
: []),
|
||||
]
|
||||
const raiser = await windowRaiser()
|
||||
const spawnAt = Date.now()
|
||||
const child = spawn(build.exe, launchArgs, { env, detached: true, stdio: "ignore" })
|
||||
child.unref()
|
||||
appPid = child.pid
|
||||
raiser.raise(child.pid!)
|
||||
|
||||
let mainProfile: Promise<unknown> | undefined
|
||||
if (profile) mainProfile = profileMain(spawnAt)
|
||||
@@ -309,6 +327,7 @@ async function launch(build: { label: string; exe: string }, run: number): Promi
|
||||
// when the shell is up and the main thread has spent under 10 % of any 500 ms window in tasks for `settleMs`.
|
||||
const seen: Record<string, number> = {}
|
||||
let last: Probe | undefined
|
||||
let prepaintSeen = false
|
||||
let quietSince: number | undefined
|
||||
let taskMs = 0
|
||||
let scriptMs = 0
|
||||
@@ -318,6 +337,9 @@ async function launch(build: { label: string; exe: string }, run: number): Promi
|
||||
const result = await cdp.send("Runtime.evaluate", { expression: probe, returnByValue: true })
|
||||
last = result.result?.result?.value as Probe | undefined
|
||||
const t = Date.now() - spawnAt
|
||||
if (last?.prepaint) prepaintSeen = true
|
||||
// Chromium marks a window it considers occluded hidden and the renderer stops painting.
|
||||
if (last?.visible && !seen.documentVisible) seen.documentVisible = t
|
||||
if (last?.shell && !seen.shellVisible) seen.shellVisible = t
|
||||
if (last?.editor && !seen.composerEditable) seen.composerEditable = t
|
||||
if (last?.rows && !seen.timelineRows) seen.timelineRows = t
|
||||
@@ -356,6 +378,7 @@ async function launch(build: { label: string; exe: string }, run: number): Promi
|
||||
const timelineResult = await cdp.send("Runtime.evaluate", { expression: rendererTimeline, returnByValue: true })
|
||||
cdp.close()
|
||||
const processes = appPid ? await processTree(appPid) : []
|
||||
const screenChanges = await raiser.screen()
|
||||
const boot = profile ? undefined : await mainBootTiming()
|
||||
await sleep(300)
|
||||
// Chromium writes the startup trace when --trace-startup-duration elapses; keep the app alive until then.
|
||||
@@ -385,7 +408,16 @@ async function launch(build: { label: string; exe: string }, run: number): Promi
|
||||
domInteractive:
|
||||
last?.domInteractive !== undefined && origin !== undefined ? Math.round(origin + last.domInteractive) : undefined,
|
||||
firstPaint: last?.firstPaint !== undefined && origin !== undefined ? Math.round(origin + last.firstPaint) : undefined,
|
||||
// With a shell snapshot in the early document, first paint is the snapshot, not the app.
|
||||
prepaintVisible:
|
||||
prepaintSeen && last?.firstPaint !== undefined && origin !== undefined
|
||||
? Math.round(origin + last.firstPaint)
|
||||
: undefined,
|
||||
...seen,
|
||||
// First sampled screen change inside the window: the ground truth for "the user sees something".
|
||||
screenPainted: screenChanges.changed[0],
|
||||
// When the sampled window content stopped changing: the interface, not a splash, is on screen.
|
||||
screenSettled: screenChanges.settled,
|
||||
rendererIdle: rendererIdleMs,
|
||||
},
|
||||
final: { url: last?.url, timelineRows: last?.rows },
|
||||
@@ -451,6 +483,22 @@ function prepareHome() {
|
||||
})
|
||||
}
|
||||
mkdirSync(paths.logs, { recursive: true })
|
||||
const at = args.values["window-at"]?.split(",").map(Number)
|
||||
if (at?.length === 2 && existsSync(userData)) {
|
||||
for (const file of readdirSync(userData).filter((name) => /^window-state-.*\.json$/.test(name))) {
|
||||
const state = JSON.parse(readFileSync(join(userData, file), "utf8"))
|
||||
const bounds = displayAt(at[0], at[1])
|
||||
writeFileSync(join(userData, file), JSON.stringify({ ...state, x: at[0], y: at[1], isMaximized: false, isFullScreen: false, displayBounds: bounds }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function displayAt(x: number, y: number) {
|
||||
if (process.platform !== "win32") return undefined
|
||||
const out = execFileSync("pwsh", ["-NoProfile", "-NonInteractive", "-Command", "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.Screen]::AllScreens | ForEach-Object { \"$($_.Bounds.X),$($_.Bounds.Y),$($_.Bounds.Width),$($_.Bounds.Height)\" }"], { encoding: "utf8" })
|
||||
const displays = out.trim().split(/\r?\n/).map((line) => line.split(",").map(Number))
|
||||
const hit = displays.find(([dx, dy, dw, dh]) => x >= dx && y >= dy && x < dx + dw && y < dy + dh)
|
||||
return hit ? { x: hit[0], y: hit[1], width: hit[2], height: hit[3] } : undefined
|
||||
}
|
||||
|
||||
async function freePort() {
|
||||
@@ -565,6 +613,86 @@ function mainLog() {
|
||||
}
|
||||
|
||||
// Working set of every process in the launched app's tree once it is idle.
|
||||
// Windows places a window launched from a background process behind the foreground one, and
|
||||
// Chromium then marks it occluded and the renderer stops painting, so paint timings would depend on
|
||||
// what else is on screen. A helper started before the app polls for its main window and pins it
|
||||
// topmost without activating it, so the user keeps their focus and the bench window is visible.
|
||||
async function windowRaiser(): Promise<{ raise: (pid: number) => void; screen: () => Promise<number[]> }> {
|
||||
if (process.platform !== "win32") return { raise: () => {}, screen: async () => [] }
|
||||
const script = join(outDir, "raise-window.ps1")
|
||||
writeFileSync(
|
||||
script,
|
||||
[
|
||||
`Add-Type -AssemblyName System.Drawing`,
|
||||
`Add-Type -Namespace Bench -Name User32 -MemberDefinition '[DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr h, IntPtr a, int x, int y, int w, int z, uint f); [StructLayout(LayoutKind.Sequential)] public struct RECT { public int L; public int T; public int R; public int B; } [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);'`,
|
||||
`[Console]::Out.WriteLine("ready")`,
|
||||
`$target = [int][Console]::In.ReadLine()`,
|
||||
`$clock = [Diagnostics.Stopwatch]::StartNew()`,
|
||||
`$h = 0`,
|
||||
`for ($i = 0; $i -lt 600; $i++) {`,
|
||||
` $h = (Get-Process -Id $target -ErrorAction SilentlyContinue).MainWindowHandle`,
|
||||
` if ($h -and $h -ne 0) { [Bench.User32]::SetWindowPos($h, [IntPtr](-1), 0, 0, 0, 0, 0x13) | Out-Null; [Console]::Out.WriteLine("raised " + $clock.ElapsedMilliseconds); break }`,
|
||||
` Start-Sleep -Milliseconds 10`,
|
||||
`}`,
|
||||
`if (-not $h -or $h -eq 0) { exit }`,
|
||||
// Sample a 16x16 grid of pixels inside the window: cheap, and enough to tell the background
|
||||
// colour, a splash and the interface apart.
|
||||
`$r = New-Object Bench.User32+RECT`,
|
||||
`while ($clock.ElapsedMilliseconds -lt 3000) {`,
|
||||
` [Bench.User32]::GetWindowRect($h, [ref]$r) | Out-Null`,
|
||||
` $w = $r.R - $r.L; $ht = $r.B - $r.T`,
|
||||
` if ($w -le 48 -or $ht -le 48) { Start-Sleep -Milliseconds 30; continue }`,
|
||||
` $t = $clock.ElapsedMilliseconds`,
|
||||
` $bmp = New-Object System.Drawing.Bitmap $w, $ht`,
|
||||
` $g = [System.Drawing.Graphics]::FromImage($bmp)`,
|
||||
` try { $g.CopyFromScreen($r.L, $r.T, 0, 0, $bmp.Size) } catch { $g.Dispose(); $bmp.Dispose(); Start-Sleep -Milliseconds 30; continue }`,
|
||||
` $sum = 0`,
|
||||
` for ($i = 1; $i -le 16; $i++) { for ($j = 1; $j -le 16; $j++) { $p = $bmp.GetPixel([int]($w * $j / 17), [int]($ht * $i / 17)); $sum += [int]$p.R + [int]$p.G + [int]$p.B } }`,
|
||||
` [Console]::Out.WriteLine("screen " + $t + " " + $sum)`,
|
||||
` if ($env:BENCH_SCREEN_DUMP) { $bmp.Save((Join-Path $env:BENCH_SCREEN_DUMP ("screen-" + $t.ToString().PadLeft(4, "0") + ".png"))) }`,
|
||||
` $g.Dispose(); $bmp.Dispose()`,
|
||||
` Start-Sleep -Milliseconds 30`,
|
||||
`}`,
|
||||
].join("\n"),
|
||||
)
|
||||
const helper = spawn("pwsh", ["-NoProfile", "-NonInteractive", "-File", script], { stdio: ["pipe", "pipe", "pipe"] })
|
||||
const lines: string[] = []
|
||||
let buffer = ""
|
||||
await new Promise<void>((resolve) => {
|
||||
helper.stdout!.on("data", (chunk: Buffer) => {
|
||||
buffer += chunk.toString()
|
||||
const parts = buffer.split(/\r?\n/)
|
||||
buffer = parts.pop() ?? ""
|
||||
for (const line of parts) {
|
||||
if (line === "ready") resolve()
|
||||
else lines.push(line)
|
||||
}
|
||||
})
|
||||
helper.stderr!.on("data", (chunk: Buffer) => console.error(`raise-window: ${chunk.toString().trim()}`))
|
||||
helper.on("exit", () => resolve())
|
||||
})
|
||||
const exited = new Promise<void>((resolve) => helper.on("exit", () => resolve()))
|
||||
return {
|
||||
raise: (pid) => helper.stdin!.write(`${pid}\n`),
|
||||
// Resolves with the times (ms since the pid was sent, ~spawn) at which the sampled screen
|
||||
// content differed from the first sample, i.e. when something other than the background colour
|
||||
// was on screen.
|
||||
screen: async () => {
|
||||
await exited
|
||||
const samples = lines
|
||||
.filter((line) => line.startsWith("screen "))
|
||||
.map((line) => line.split(" ").slice(1).map(Number) as [number, number])
|
||||
if (process.env.BENCH_DEBUG) console.log(lines.filter((line) => line.startsWith("raised")).join(" "), `${samples.length} screen samples`)
|
||||
const first = samples[0]?.[1]
|
||||
const last = samples.at(-1)?.[1]
|
||||
const differs = (a: number, b: number) => Math.abs(a - b) > 16 * 16 * 12
|
||||
const changed = samples.filter(([, sum]) => differs(sum, first)).map(([t]) => t)
|
||||
// The last sample that still differed from the final content, i.e. when the window stopped changing.
|
||||
const settledIndex = samples.findLastIndex(([, sum]) => last !== undefined && differs(sum, last))
|
||||
return { changed, settled: settledIndex >= 0 ? samples[settledIndex + 1]?.[0] : samples[0]?.[0] }
|
||||
},
|
||||
}
|
||||
}
|
||||
async function processTree(root: number) {
|
||||
const script =
|
||||
process.platform === "win32"
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { listInstalledWslDistros, probeWslRuntime, runWslSh, wslArgs } from "./runtime"
|
||||
|
||||
test("wslArgs bypasses the distro default shell", () => {
|
||||
expect(wslArgs(["sh", "-lc", 'printf "%s\\n" "$HOME"'], "Debian")).toEqual([
|
||||
"-d",
|
||||
"Debian",
|
||||
"--exec",
|
||||
"sh",
|
||||
"-lc",
|
||||
'printf "%s\\n" "$HOME"',
|
||||
])
|
||||
expect(wslArgs(["bash", "-se"], "Debian", "root")).toEqual([
|
||||
"-d",
|
||||
"Debian",
|
||||
"--user",
|
||||
"root",
|
||||
"--exec",
|
||||
"bash",
|
||||
"-se",
|
||||
])
|
||||
})
|
||||
|
||||
// Exercise the real wsl.exe argv path: with `--`, WSL routes the command line through the distro's
|
||||
// default shell, which expands `$cli` to "" before `sh` runs the script (#48640).
|
||||
const distro =
|
||||
process.platform === "win32" && (await probeWslRuntime()).available
|
||||
? (await listInstalledWslDistros().catch(() => [])).find((item) => item.isDefault)?.name
|
||||
: undefined
|
||||
|
||||
test.skipIf(!distro)("inline scripts keep their own variable expansion inside WSL", async () => {
|
||||
const result = await runWslSh(['cli="from-script"', 'printf "%s\\n" "$cli"'].join("\n"), distro)
|
||||
expect(result.code).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("from-script")
|
||||
})
|
||||
@@ -38,7 +38,9 @@ const DEFAULT_WSL_TIMEOUT_MS = 20_000
|
||||
const DEFAULT_WSL_INSTALL_TIMEOUT_MS = 15 * 60_000
|
||||
|
||||
export function wslArgs(args: string[], distro?: string | null, user?: string | null) {
|
||||
return [...(distro ? ["-d", distro] : []), ...(user ? ["--user", user] : []), "--", ...args]
|
||||
// `--` hands the command line to the distro's default shell, which expands `$VAR` and `$(...)`
|
||||
// inside our inline scripts before `sh -lc` ever runs them. `--exec` runs the command directly.
|
||||
return [...(distro ? ["-d", distro] : []), ...(user ? ["--user", user] : []), "--exec", ...args]
|
||||
}
|
||||
|
||||
export function runWsl(args: string[], opts: RunWslOptions = {}) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"name": "@opencode/http-recorder",
|
||||
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"description": "OpenCode's desktop browser plugin",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -273,6 +273,8 @@ export interface ToastOptions {
|
||||
readonly message: string
|
||||
readonly variant?: ToastVariant
|
||||
readonly duration?: number
|
||||
/** When this session's family is not open, the title defaults to the session title and the toast offers to open it. */
|
||||
readonly sessionID?: string
|
||||
}
|
||||
|
||||
export interface Toast {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"name": "@opencode/sdk",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,24 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { typeLabel } from "./message-file"
|
||||
import type { PromptFileAttachment } from "@opencode/client/promise"
|
||||
import { attached, typeLabel } from "./message-file"
|
||||
|
||||
function file(source: PromptFileAttachment["source"], mention = false): PromptFileAttachment {
|
||||
return {
|
||||
data: "",
|
||||
mime: "text/plain",
|
||||
source,
|
||||
...(mention ? { mention: { text: "@README.md", start: 0, end: 10 } } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
describe("message-file", () => {
|
||||
test("only treats data-backed files without mentions as attachments", () => {
|
||||
expect(attached(file({ type: "inline" }))).toBe(true)
|
||||
expect(attached(file({ type: "uri", uri: "data:text/plain;base64,SGVsbG8=" }))).toBe(true)
|
||||
expect(attached(file({ type: "uri", uri: "file:///repo/README.md" }))).toBe(false)
|
||||
expect(attached(file({ type: "inline" }, true))).toBe(false)
|
||||
})
|
||||
|
||||
test("labels attachment types from the basename extension", () => {
|
||||
expect(typeLabel("list.md", "text/plain", "File")).toBe("Markdown")
|
||||
expect(typeLabel("/repo/src/main.ts", "text/plain", "File")).toBe("TypeScript")
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { bundledLanguagesInfo } from "shiki"
|
||||
import { getFilename } from "@opencode/util/path"
|
||||
import type { PromptFileAttachment } from "@opencode/client/promise"
|
||||
|
||||
export function attached(file: PromptFileAttachment) {
|
||||
if (file.mention) return false
|
||||
if (file.source.type === "inline") return true
|
||||
return file.source.uri.startsWith("data:")
|
||||
}
|
||||
|
||||
// language metadata only; grammars stay behind shiki's lazy imports
|
||||
const LANGUAGE_NAMES = new Map<string, string>(
|
||||
|
||||
@@ -27,7 +27,7 @@ import type {
|
||||
SessionMessageUser,
|
||||
} from "@opencode/client/promise"
|
||||
import type { SessionUserActions, SessionUserAttachmentReference, SessionUserComment } from "../actions"
|
||||
import { typeLabel } from "../components/message-file"
|
||||
import { attached, typeLabel } from "../components/message-file"
|
||||
|
||||
export async function writeClipboard(text: string): Promise<boolean> {
|
||||
const body = typeof document === "undefined" ? undefined : document.body
|
||||
@@ -214,7 +214,7 @@ export function CurrentUserMessageDisplay(props: {
|
||||
const dialog = useDialog()
|
||||
const i18n = useI18n()
|
||||
const [state, setState] = createStore({ copied: false, reverting: false })
|
||||
const attachments = createMemo(() => (props.message.files ?? []).filter((file) => !file.mention))
|
||||
const attachments = createMemo(() => (props.message.files ?? []).filter(attached))
|
||||
const references = createMemo(() => props.references ?? [])
|
||||
const inlineFiles = createMemo(() => (props.message.files ?? []).filter((file) => !!file.mention))
|
||||
const agents = createMemo(() => props.message.agents ?? [])
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.10",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user