Compare commits

...
Author SHA1 Message Date
rekram1-node 2fa63c521d feat(core): announce top-level tool availability changes
Track the direct tool names shown to the model as an instruction source so
later requests announce only what was added or removed, phrased like the
Code Mode catalog updates. The request's own tool list is the baseline, so
instruction sources may now omit an initial render and nothing is said until
the set changes.
2026-09-19 20:30:50 -05:00
Aiden Cline eb0e26b974 feat(codemode): return live iterators from keys, values, and entries (#50061) 2026-09-19 18:46:35 -05:00
opencode-agent[bot] 6f2b0e7833 chore(core): refresh bundled models.dev snapshot 2026-09-19 23:38:19 +00:00
opencode-agent[bot] f153255942 chore(core): refresh bundled models.dev snapshot 2026-09-19 22:37:11 +00:00
opencode-agent[bot] fef2fad76f chore(core): refresh bundled models.dev snapshot 2026-09-19 22:15:20 +00:00
Aiden Cline f30d06ea34 fix(core): pin the session to http after repeated websocket stream losses (#50031) 2026-09-19 16:45:54 -05:00
Kit Langton dfa44e94e8 fix(tui): use focused action token for question form border (#50037) 2026-09-19 19:07:10 +00:00
Aiden Cline b81e10a461 fix(core): re-read models.dev catalog after subscribing to Refreshed (#50012) 2026-09-19 13:31:38 -05:00
Aiden Cline 65c93b69ed fix(ai): never fail decoding on Responses error frames (#49402) 2026-09-19 13:27:05 -05:00
Aiden Cline b073b052d3 fix(ai): remove bounded websocket inbound queues (#50026) 2026-09-19 13:26:37 -05:00
Aiden Cline 728b2b6052 fix(core): honor session permissions in skill and MCP discovery (#50015) 2026-09-19 12:59:09 -05:00
Aiden Cline cc3ce20ab4 feat(codemode): add the Headers built-in (#49280) 2026-09-19 12:35:14 -05:00
Aiden Cline 4ad5001be2 chore(core): refresh bundled models.dev snapshot (#50009) 2026-09-19 12:24:10 -05:00
Matt SimpsonandAiden Cline 14b3c2ea4b feat(cli): support Vite+ installations (#49624)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-09-19 12:16:47 -05:00
opencode-agent[bot]andrekram1-node 558bd54c9b fix(tui): use muted autocomplete label text (#50008)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-19 12:16:41 -05:00
Filip 7e70f7e1ab feat(tui): label mention autocomplete options (#48551) 2026-09-19 16:05:03 +02:00
opencode cb6d95b7ef sync release versions for v2.0.10 2026-09-19 13:40:59 +00:00
opencode-agent[bot]andBrendonovich 7020944359 fix(app): restore mobile tab interactions (#49964)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-19 21:00:45 +08:00
Shoubhit Dash 4b00dd2713 feat(tui): session-scoped plugin toasts (#49962) 2026-09-19 18:24:38 +05:30
Chris YangandBrendan Allan b447627f5f fix(app): keep shortcuts search field focused while typing (#49779)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-09-19 20:43:58 +08:00
opencode-agent[bot]andBrendonovich 5848ee0d24 fix(app): restore attachment classification (#49932)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-19 09:13:29 +00:00
Dax Raad 839aa25c3e feat(cli): publish Homebrew formula 2026-09-19 04:37:36 -04:00
Dax Raad c42f1c9232 Revert "feat(web): add v2 announcement banner to legacy docs"
This reverts commit 7b7a67080e.
2026-09-19 03:24:40 -04:00
Kit Langton 417f6d234d fix(tui): restore running and unread status hue aliases (#49907) 2026-09-19 02:05:53 -04:00
opencode 45a13af0ee sync release versions for v2.0.9 2026-09-19 05:13:01 +00:00
Luke Parker e50d845451 fix(desktop): run WSL commands with --exec so inline scripts are not pre-expanded (#49902) 2026-09-19 14:36:04 +10:00
Kit Langton 56816621f8 fix(tui): restore foreground hue step for tab status colors (#49899) 2026-09-19 04:33:23 +00:00
Luke Parker 7125f5f8b5 chore(desktop): measure the startup bench from the screen (#49894) 2026-09-19 14:06:58 +10:00
opencode-agent[bot] 01a6ed8d97 chore: update nix node_modules hashes 2026-09-19 02:49:44 +00:00
004583d598 refactor(codemode): remove TypeScript stripping (#49870)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-09-18 21:24:11 -05:00
126 changed files with 2165 additions and 737 deletions
+35 -36
View File
@@ -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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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" }>,
+28 -94
View File
@@ -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])
}
+4 -14
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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": {
+120
View File
@@ -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}` },
})
+4
View File
@@ -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) {
+39 -9
View File
@@ -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 })))
+46 -4
View File
@@ -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"],
])
}),
)
+7 -1
View File
@@ -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 -1
View File
@@ -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": {
+14 -6
View File
@@ -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
+33 -27
View File
@@ -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.
+2 -9
View File
@@ -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:",
+9 -7
View File
@@ -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`
+2
View File
@@ -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
View File
@@ -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(),
+21 -1
View File
@@ -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 })
+11 -5
View File
@@ -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
}
+11 -5
View File
@@ -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
}
+24 -8
View File
@@ -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
}
+2
View File
@@ -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]"
+132
View File
@@ -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
}
+19
View File
@@ -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) })
},
],
])
}
+14 -3
View File
@@ -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
}
+50 -23
View File
@@ -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
}
+2
View File
@@ -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",
+13 -7
View File
@@ -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 () => {
+30 -1
View File
@@ -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])
})
+2 -2
View File
@@ -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])
+264 -4
View File
@@ -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
+256
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-app",
"version": "2.0.8",
"version": "2.0.10",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-support",
"version": "2.0.8",
"version": "2.0.10",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -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",
+4 -2
View File
@@ -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 [
{
+6 -8
View File
@@ -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 []
File diff suppressed because one or more lines are too long
+10 -7
View File
@@ -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)
}),
})
+7 -3
View File
@@ -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,
+28 -21
View File
@@ -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
+2 -3
View File
@@ -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)
+5 -6
View File
@@ -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
? []
+36
View File
@@ -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 },
})
+22 -26
View File
@@ -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)
}),
)
})
+23 -11
View File
@@ -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: [] })
+1 -1
View File
@@ -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([])
}),
)
})
+11 -11
View File
@@ -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 -1
View File
@@ -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",
+134 -6
View File
@@ -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")
})
+3 -1
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/enterprise",
"version": "2.0.8",
"version": "2.0.10",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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": {
+2
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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