Compare commits

...
34 changed files with 803 additions and 351 deletions
@@ -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")
}),
)
+65 -21
View File
@@ -60,7 +60,17 @@ type ToolState = StartedPart & {
}
type V2Event = EventSubscribeOutput
type FormRequest = Extract<V2Event, { type: "form.created" }>["data"]["form"]
type FormRequest = {
id: string
sessionID: string
metadata?: Readonly<Record<string, unknown>>
fields: ReadonlyArray<{
key: string
type: string
default?: unknown
options?: ReadonlyArray<{ value: string }>
}>
}
// MCP elicitations are temporarily owned by the "global" sentinel instead of a real
// session. An exclusive local process may treat them as this run's blockers; an
@@ -79,6 +89,7 @@ export async function runNonInteractivePrompt(input: Input) {
const renderedText = new Map<string, string>()
const renderedReasoning = new Map<string, string>()
const renderedTools = new Set<string>()
const sessions = new Set([input.sessionID])
let submitted = false
let promoted = false
let emittedError = false
@@ -132,7 +143,12 @@ export async function runNonInteractivePrompt(input: Input) {
}
}
const replyPermission = async (request: { id: string; action: string; resources: ReadonlyArray<string> }) => {
const replyPermission = async (request: {
id: string
sessionID: string
action: string
resources: ReadonlyArray<string>
}) => {
if (!input.auto) {
permissionRejected = true
UI.println(
@@ -143,13 +159,13 @@ export async function runNonInteractivePrompt(input: Input) {
}
await input.client.permission
.reply({
sessionID: input.sessionID,
sessionID: request.sessionID,
requestID: request.id,
decision: input.auto ? "once" : "reject",
})
.catch(() => {})
if (!input.auto) {
await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
await input.client.session.interrupt({ sessionID: request.sessionID }).catch(() => {})
}
}
@@ -165,6 +181,23 @@ export async function runNonInteractivePrompt(input: Input) {
formCancelled = true
}
const settleForm = async (request: FormRequest) => {
const field =
request.metadata?.kind === "websearch.provider"
? request.fields.find((field) => field.type === "string" && field.options?.length)
: undefined
const value = typeof field?.default === "string" ? field.default : field?.options?.[0]?.value
if (!field || value === undefined) return cancelForm(request)
try {
await input.client.session.form.reply(
{ sessionID: request.sessionID, formID: request.id, answer: { [field.key]: value } },
...formRequestOptions(request.sessionID === GLOBAL_FORM_SESSION_ID ? input.location : undefined),
)
} catch (error) {
if (!formAlreadySettled(error)) throw error
}
}
const consume = async () => {
while (!controller.signal.aborted) {
const next = await stream.next().catch((error) => {
@@ -177,19 +210,23 @@ export async function runNonInteractivePrompt(input: Input) {
}
const event = next.value
if (event.type === "permission.asked" && submitted && event.data.sessionID === input.sessionID) {
if (event.type === "session.created" && event.data.parentID && sessions.has(event.data.parentID)) {
sessions.add(event.data.sessionID)
continue
}
if (event.type === "permission.asked" && submitted && sessions.has(event.data.sessionID)) {
await replyPermission(event.data)
continue
}
if (
event.type === "form.created" &&
submitted &&
(event.data.form.sessionID === input.sessionID ||
(sessions.has(event.data.form.sessionID) ||
(!input.attached &&
event.data.form.sessionID === GLOBAL_FORM_SESSION_ID &&
sameLocation(event.location, input.location)))
) {
await cancelForm(event.data.form)
await settleForm(event.data.form)
continue
}
if (!("sessionID" in event.data) || event.data.sessionID !== input.sessionID) continue
@@ -476,28 +513,31 @@ export async function runNonInteractivePrompt(input: Input) {
if (interrupted || permissionRejected || formCancelled) continue
flushStep()
emittedError = true
process.exitCode = 1
if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message)
continue
}
if (event.type === "session.execution.failed") {
if (input.compatibility === "v1" && (v1InvalidOutput || permissionRejected || formCancelled)) return
flushStep()
if (!emittedError && !formCancelled) {
emittedError = true
if (!formCancelled) {
process.exitCode = 1
if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message)
if (!emittedError) {
emittedError = true
if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message)
}
}
return
}
if (event.type === "session.execution.interrupted") {
if (input.compatibility === "v1" && (permissionRejected || formCancelled)) return
if (event.data.reason === "user" && interrupted) process.exitCode = 130
if (event.data.reason !== "user" && !emittedError) {
emittedError = true
if (event.data.reason !== "user") {
process.exitCode = 1
const error = { type: "aborted" as const, message: `Session interrupted: ${event.data.reason}` }
if (!emit("error", time, { error })) UI.error(error.message)
if (!emittedError) {
emittedError = true
const error = { type: "aborted" as const, message: `Session interrupted: ${event.data.reason}` }
if (!emit("error", time, { error })) UI.error(error.message)
}
}
return
}
@@ -525,9 +565,11 @@ export async function runNonInteractivePrompt(input: Input) {
const reconcile = async () => {
const projected = await projectedMessages()
let projectedError: { error: { message: string; [key: string]: unknown }; timestamp: number } | undefined
for (const message of projected.messages) {
if (message.type !== "assistant") continue
const timestamp = message.time.completed ?? message.time.created
projectedError = message.error ? { error: message.error, timestamp } : undefined
let textOrdinal = 0
let reasoningOrdinal = 0
for (const item of message.content) {
@@ -619,11 +661,13 @@ export async function runNonInteractivePrompt(input: Input) {
await input.renderToolError(item)
UI.error(item.state.error.message)
}
if (message.error && !emittedError) {
}
if (projectedError && !interrupted && !permissionRejected && !formCancelled) {
process.exitCode = 1
if (!emittedError) {
emittedError = true
process.exitCode = 1
if (!emit("error", timestamp, { error: message.error })) UI.error(message.error.message)
if (!emit("error", projectedError.timestamp, { error: projectedError.error }))
UI.error(projectedError.error.message)
}
}
return {
@@ -706,9 +750,9 @@ export async function runNonInteractivePrompt(input: Input) {
])
await Promise.all([
...(permissions ?? []).map(replyPermission),
...(forms ?? []).map(cancelForm),
...(forms ?? []).map(settleForm),
...(globals && sameLocation(globals.location, input.location)
? globals.data.filter((form) => form.sessionID === GLOBAL_FORM_SESSION_ID).map(cancelForm)
? globals.data.filter((form) => form.sessionID === GLOBAL_FORM_SESSION_ID).map(settleForm)
: []),
])
if (input.compatibility === "v1") {
+165 -3
View File
@@ -24,6 +24,27 @@ function form(id: string, sessionID: string): FormInfo {
}
}
function webSearchForm(id: string, sessionID: string): FormInfo {
return {
id,
sessionID,
title: "Web Search",
metadata: { kind: "websearch.provider" },
fields: [
{
key: "choice",
type: "string",
required: true,
custom: false,
options: [
{ value: "allow", label: "Allow search" },
{ value: "disable", label: "Disable search" },
],
},
],
}
}
function formCreated(info: FormInfo, eventLocation = location): V2Event {
return { id: `evt_${info.id}`, created: 0, type: "form.created", location: eventLocation, data: { form: info } }
}
@@ -38,6 +59,37 @@ function prompted(inboxID: string): V2Event {
}
}
function childCreated(): V2Event {
return {
id: "evt_child_created",
created: 0,
type: "session.created",
durable: { aggregateID: "ses_child", seq: 0, version: 1 },
data: {
sessionID: "ses_child",
projectID: "proj_1",
location,
parentID: "ses_1",
slug: "child",
version: "test",
},
}
}
function permissionAsked(sessionID: string): V2Event {
return {
id: "evt_permission",
created: 1,
type: "permission.asked",
data: {
id: "per_1",
sessionID,
action: "shell",
resources: ["rm file"],
},
}
}
function settled(outcome: "success" | "interrupted" = "success"): V2Event {
if (outcome === "interrupted")
return {
@@ -210,9 +262,11 @@ async function run(input: {
turn: (inboxID: string) => V2Event[]
pendingForms?: FormInfo[]
attached?: boolean
auto?: boolean
format?: "default" | "json"
compatibility?: "v1"
cancel?: (input: { sessionID: string; formID: string }) => Promise<void>
reply?: (input: { sessionID: string; formID: string; answer: Record<string, unknown> }) => Promise<void>
renderTool?: (part: SessionMessageAssistantTool) => Promise<void>
renderToolError?: (part: SessionMessageAssistantTool) => Promise<void>
messages?: (inboxID: string) => SessionMessageInfo[]
@@ -241,6 +295,7 @@ async function run(input: {
})()
spyOn(sdk.event, "subscribe").mockImplementation(() => stream)
spyOn(sdk.permission, "list").mockImplementation(() => ok([]) as never)
spyOn(sdk.permission, "reply").mockImplementation(() => ok(undefined) as never)
spyOn(sdk.session.form, "list").mockImplementation(
(request) => ok(input.pendingForms?.filter((item) => item.sessionID === request.sessionID) ?? []) as never,
)
@@ -252,6 +307,8 @@ async function run(input: {
}) as never,
)
spyOn(sdk.session.form, "cancel").mockImplementation((request) => (input.cancel?.(request) ?? ok(undefined)) as never)
spyOn(sdk.session.form, "reply").mockImplementation((request) => (input.reply?.(request) ?? ok(undefined)) as never)
spyOn(sdk.session, "interrupt").mockImplementation(() => ok(undefined) as never)
let promptID = "msg_prompt"
spyOn(sdk.session, "wait").mockImplementation(() => input.wait?.() ?? wait.promise)
spyOn(sdk.message, "list").mockImplementation(() =>
@@ -276,7 +333,7 @@ async function run(input: {
files: [],
thinking: false,
format: input.format ?? "default",
auto: false,
auto: input.auto ?? false,
attached: input.attached ?? false,
compatibility: input.compatibility,
renderTool: input.renderTool ?? (() => Promise.resolve()),
@@ -312,6 +369,105 @@ afterEach(() => {
})
describe("runNonInteractivePrompt", () => {
test("keeps exit zero when a failed step is recovered", async () => {
const output = await capture({
format: "json",
turn: (messageID) => [prompted(messageID), stepStarted(), stepFailed("socket closed"), settled()],
})
expect(output.exitCode ?? 0).toBe(0)
expect(output.stdout).toContain('"type":"error"')
expect(output.stdout).toContain("socket closed")
})
test("keeps terminal execution failures fatal after a failed step", async () => {
const output = await capture({
format: "json",
turn: (messageID) => [prompted(messageID), stepFailed("socket closed"), executionFailed("retries exhausted")],
})
expect(output.exitCode).toBe(1)
})
test("does not infer failure from a recovered projected step", async () => {
const output = await capture({
format: "json",
turn: (messageID) => [prompted(messageID), settled()],
messages: (messageID) => [
{
id: "msg_success",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "test-model" },
content: [{ type: "text", text: "recovered" }],
finish: "stop",
time: { created: 4, completed: 5 },
},
{
id: "msg_failed",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "test-model" },
content: [],
finish: "error",
error: { type: "provider.transport", message: "socket closed" },
time: { created: 2, completed: 3 },
},
{ id: messageID, type: "user", text: "hello", time: { created: 1 } },
],
})
expect(output.exitCode).toBe(0)
expect(output.stdout).toContain("recovered")
})
test("selects the default web search option instead of cancelling", async () => {
const sdk = await run({
turn: (messageID) => [formCreated(webSearchForm("frm_search", "ses_1")), prompted(messageID), settled()],
})
expect(sdk.session.form.reply).toHaveBeenCalledWith({
sessionID: "ses_1",
formID: "frm_search",
answer: { choice: "allow" },
})
expect(sdk.session.form.cancel).not.toHaveBeenCalled()
})
test("rejects blockers owned by child sessions", async () => {
const sdk = await run({
turn: (messageID) => [
prompted(messageID),
childCreated(),
permissionAsked("ses_child"),
formCreated(form("frm_child", "ses_child")),
settled(),
],
})
expect(sdk.permission.reply).toHaveBeenCalledWith({
sessionID: "ses_child",
requestID: "per_1",
decision: "reject",
})
expect(sdk.session.interrupt).toHaveBeenCalledWith({ sessionID: "ses_child" })
expect(sdk.session.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_child", formID: "frm_child" })
})
test("auto-approves permissions owned by child sessions", async () => {
const sdk = await run({
auto: true,
turn: (messageID) => [prompted(messageID), childCreated(), permissionAsked("ses_child"), settled()],
})
expect(sdk.permission.reply).toHaveBeenCalledWith({
sessionID: "ses_child",
requestID: "per_1",
decision: "once",
})
expect(sdk.session.interrupt).not.toHaveBeenCalled()
})
test("keeps formatted tool output and compact tool metadata in JSON", async () => {
const output = await capture({ format: "json", turn: successfulGrep })
const events = output.stdout
@@ -429,7 +585,10 @@ describe("runNonInteractivePrompt", () => {
}
expect(sdk.session.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }, globalOptions)
expect(sdk.session.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
expect(sdk.session.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" }, globalOptions)
expect(sdk.session.form.cancel).toHaveBeenCalledWith(
{ sessionID: "global", formID: "frm_pending_global" },
globalOptions,
)
expect(sdk.form.list).toHaveBeenCalledWith({
location: { directory: "/work tree" },
})
@@ -443,7 +602,10 @@ describe("runNonInteractivePrompt", () => {
})
expect(sdk.session.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
expect(sdk.form.list).not.toHaveBeenCalled()
expect(sdk.session.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }, expect.anything())
expect(sdk.session.form.cancel).not.toHaveBeenCalledWith(
{ sessionID: "global", formID: "frm_live" },
expect.anything(),
)
expect(sdk.session.form.cancel).not.toHaveBeenCalledWith(
{ sessionID: "global", formID: "frm_pending_global" },
expect.anything(),
+15 -11
View File
@@ -53,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.
@@ -92,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, Headers, 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`.
@@ -287,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`.
@@ -302,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
@@ -314,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
@@ -405,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`,
@@ -421,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
@@ -434,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) [...]`.
@@ -450,8 +454,8 @@ with a hint to encode as text first (`TextDecoder`, `toBase64`, `toHex`).
`fatal` and `ignoreBOM` options; `decode` takes a Uint8Array or nothing.
- [x] `new Headers()` from records, synchronous iterables of pairs, and Headers, wrapping the host's `Headers`: names
fold to lowercase, values are normalized and combined, and invalid names or values throw a `TypeError`.
- [x] Headers `append`, `delete`, `get`, `getSetCookie`, `has`, `set`, `forEach`, `keys`, `values`, and `entries`;
iteration is live and sorted by name, with `set-cookie` values kept apart.
- [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.
@@ -20,6 +20,7 @@ import {
DateObj,
ErrorObj,
GeneratorObj,
IteratorObj,
MapObj,
Obj,
PromiseObj,
@@ -61,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.`)
@@ -12,6 +12,7 @@ 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"
@@ -101,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,6 +79,7 @@ import {
DateObj,
Fn,
GeneratorObj,
IteratorObj,
MapObj,
Obj,
PromiseObj,
@@ -650,8 +651,8 @@ 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, URLSearchParams, or Headers, or custom iterator value.`,
@@ -746,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]()
@@ -761,29 +776,21 @@ class Frame<R> {
? value.headers.entries()
: value instanceof Bytes
? value.bytes.values()
: undefined
if (iterator !== undefined) {
const proto = this.ctx.builtins.Array
return Effect.succeed({
next: Effect.sync(() => {
const step = iterator.next()
return {
done: Boolean(step.done),
value: Array.isArray(step.value) ? new Arr(proto, step.value) : step.value,
}
}),
close: Effect.void,
})
: 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) {
@@ -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,
@@ -9,6 +9,7 @@ import {
Bytes,
DateObj,
GeneratorObj,
IteratorObj,
MapObj,
Obj,
PromiseObj,
@@ -23,6 +24,7 @@ import {
export const isRuntimeReference = (value: unknown): boolean =>
value instanceof Callable ||
value instanceof GeneratorObj ||
value instanceof IteratorObj ||
value instanceof ToolReference ||
value instanceof PromiseObj ||
isWrapper(value)
@@ -89,6 +91,7 @@ export const describeValue = (value: unknown): string => {
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}`
+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
}
+18 -5
View File
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import { constructor, methods, prototypeFrom, receiver, requiresNew } from "../interpreter/native.js"
import { typeError } from "../interpreter/model.js"
import { entries, Arr, HeadersObj, Obj } from "../interpreter/objects.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"
@@ -93,13 +93,25 @@ export const headersGlobal = <R>(ctx: Interpreter<R>) => {
return attempt(() => target.set(arg(args, 0), arg(args, 1)))
},
],
["keys", 0, (thisValue) => wrap(Array.from(self(thisValue, "keys").headers.keys()))],
["values", 0, (thisValue) => wrap(Array.from(self(thisValue, "values").headers.values()))],
// 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) =>
wrap(Array.from(self(thisValue, "entries").headers.entries(), ([key, value]) => wrap([key, value]))),
new IteratorObj(
builtins.Iterator,
Iterator.from(self(thisValue, "entries").headers.entries()).map(([key, value]) => wrap([key, value])),
),
],
[
"forEach",
@@ -115,5 +127,6 @@ export const headersGlobal = <R>(ctx: Interpreter<R>) => {
},
],
])
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
}
+23 -5
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"
@@ -258,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()],
[
@@ -281,5 +298,6 @@ export const urlSearchParamsGlobal = <R>(ctx: Interpreter<R>) => {
},
],
])
define(proto, IteratorSymbol, get(proto, "entries"), hidden)
return searchParams
}
@@ -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",
+2 -1
View File
@@ -220,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])
+119 -7
View File
@@ -655,9 +655,9 @@ describe("Headers", () => {
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(),
keys: [...headers.keys()],
values: [...headers.values()],
entries: [...headers.entries()],
}
`),
).toEqual({
@@ -816,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],
],
})
})
@@ -1119,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
+42 -11
View File
@@ -8,8 +8,8 @@
* 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.
* Headers cases that need `Symbol.iterator`, iterator objects from `keys()`/`values()`/`entries()` (CodeMode returns
* arrays), or a custom iterator on a Headers instance are left out.
* `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"
@@ -253,33 +253,64 @@ describe("Headers WPT parity (fetch/api/headers)", () => {
}).sort()
test(function() {
var headers = new Headers(headerEntriesDict)
assert_array_equals(headers.keys(), sortedHeaderKeys)
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)
assert_array_equals(headers.values(), sortedHeaderKeys.map((key) => sortedHeaderDict[key]))
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)
assert_array_equals(headers.entries(), sortedHeaderKeys.map((key) => [key, sortedHeaderDict[key]]))
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)
assert_array_equals([...headers], sortedHeaderKeys.map((key) => [key, sortedHeaderDict[key]]))
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 index = 0
var reference = sortedHeaderKeys[Symbol.iterator]()
headers.forEach(function(value, key, container) {
assert_equals(headers, container)
assert_equals(key, sortedHeaderKeys[index])
assert_equals(value, sortedHeaderDict[sortedHeaderKeys[index]])
index++
const entry = reference.next()
assert_false(entry.done)
assert_equals(key, entry.value)
assert_equals(value, sortedHeaderDict[entry.value])
})
assert_equals(index, sortedHeaderKeys.length)
assert_true(reference.next().done)
}, "Check forEach method")
test(() => {
const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0"})
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)
}),
})
+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
@@ -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)
}),
)
})
+2 -4
View File
@@ -10,7 +10,7 @@ import {
type TextareaRenderable,
} from "@opentui/core"
import open from "open"
import { useTheme, useThemes } from "../../context/theme"
import { useTheme } from "../../context/theme"
import type { FormAnswer, FormField, FormValue } from "@opencode/client"
import { useData, type FormWithLocation } from "../../context/data"
import { useClipboard } from "../../context/clipboard"
@@ -58,9 +58,7 @@ const drafts = new Map<string, FormDraft>()
export function FormPrompt(props: { form: FormWithLocation }) {
const data = useData()
const themes = useThemes()
const theme = useTheme()
const themeMode = themes.mode
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const keymap = Keymap.use()
@@ -770,7 +768,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
<box
backgroundColor={theme.background.raised.base}
border={["left"]}
borderColor={theme.hue.interactive[themeMode() === "light" ? 800 : 200]}
borderColor={theme.background.action.primary.focused}
customBorderChars={SplitBorder.customBorderChars}
>
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>