Compare commits

..
193 changed files with 1405 additions and 5937 deletions
@@ -153,15 +153,6 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
const rejection = code(event)
if (rejection === "previous_response_not_found") return rejected(observation, "retry-full")
if (rejection === "websocket_connection_limit_reached") return rejected(observation, "rotate-and-retry-full")
// Only the continuation distinguishes an incremental send from a full one, so an unclassified
// invalid request there is retried full; Codex reports a stale previous_response_id that way, with
// no code. Classified failures such as context overflow keep their runner-owned recovery.
if (
create.mode === "incremental" &&
observation.error.reason._tag === "InvalidRequest" &&
observation.error.reason.classification === undefined
)
return rejected(observation, "retry-full")
}
if (observation.type !== "completed") return observation
// A trigger installs a different context window. Clear the append baseline, retaining the socket.
@@ -181,7 +172,7 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
responseID,
request,
// Completion can re-encrypt reasoning. Callers replay the item already emitted by output_item.done.
output: event.response?.output?.length
output: event.response?.output
? event.response.output.map((item) =>
item.type === "reasoning" && item.id !== undefined
? (output.find((done) => done.type === item.type && done.id === item.id) ?? item)
+1 -2
View File
@@ -655,8 +655,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
type: "message" as const,
...(group.id === undefined ? {} : { id: group.id }),
role: "assistant" as const,
// Replayed text is a finished input item, even if generation was cut short.
status: "completed",
status: metadata?.status,
content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
...(group.phase === undefined ? {} : { phase: group.phase }),
})),
@@ -1,8 +1,7 @@
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import type { ProviderPackage } from "../provider-package.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { OpenResponses } from "../protocols/open-responses.js"
import { OpenAIResponses } from "../protocols/openai-responses.js"
import { BedrockAuth, type Credentials } from "../protocols/utils/bedrock-auth.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options.js"
@@ -38,10 +37,11 @@ const responsesRoute = Route.make({
id: "bedrock-mantle-responses",
provider: id,
providerMetadataKey: "mantle",
protocol: OpenResponses.protocol,
endpoint: Endpoint.path(OpenResponses.PATH),
transport: OpenResponses.httpTransport,
defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } },
protocol: OpenAIResponses.protocol,
endpoint: OpenAIResponses.route.endpoint,
auth: OpenAIResponses.route.auth,
transport: OpenAIResponses.httpTransport,
defaults: OpenAIResponses.route.defaults,
})
const chatRoute = OpenAIChat.route.with({
+2 -5
View File
@@ -115,11 +115,8 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
}
const onAbort = () => {
cleanup()
if (ws.readyState === globalThis.WebSocket.CLOSED || ws.readyState === globalThis.WebSocket.CLOSING) return
// Node's ws reports an aborted handshake as an error event on the next tick; with no listener left
// after cleanup, EventEmitter would throw it as an uncaught exception.
ws.addEventListener("error", () => {}, { once: true })
ws.close(1000)
if (ws.readyState !== globalThis.WebSocket.CLOSED && ws.readyState !== globalThis.WebSocket.CLOSING)
ws.close(1000)
}
const onOpen = () => {
cleanup()
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -81,7 +81,7 @@
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"text\":\"Alpha.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Beta.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Follow the user's exact reply instruction.\"}"
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Alpha.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Beta.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Follow the user's exact reply instruction.\"}"
},
{
"direction": "server",
@@ -91,7 +91,7 @@
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"text\":\"Ready.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Follow the user's exact reply instruction.\"}"
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Ready.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Follow the user's exact reply instruction.\"}"
},
{
"direction": "server",
File diff suppressed because one or more lines are too long
@@ -4,7 +4,7 @@ import { HttpClientRequest } from "effect/unstable/http"
import { LLM, Message } from "../../src/index.js"
import { AmazonBedrockMantle } from "../../src/providers.js"
import { model } from "../../src/providers/amazon-bedrock/mantle.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
import { compileRequest, LLMClient } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { withProcessEnv } from "../lib/env.js"
@@ -25,7 +25,7 @@ describe("Amazon Bedrock Mantle provider", () => {
expect(provider.model).toBe(provider.responses)
expect(AmazonBedrockMantle.model).toBe(AmazonBedrockMantle.responsesModel)
expect(model).toBe(AmazonBedrockMantle.responsesModel)
expect(provider.model("openai.gpt-oss-120b").route.transport).toBe(OpenResponses.httpTransport)
expect(provider.model("openai.gpt-oss-120b").route.transport).toBe(OpenAIResponses.httpTransport)
const chat = yield* compileRequest(LLM.request({ model: provider.chat("openai.gpt-oss-120b"), prompt: "Hi" }))
const responses = yield* compileRequest(
LLM.request({ model: provider.model("openai.gpt-oss-120b"), prompt: "Hi" }),
@@ -38,7 +38,7 @@ describe("Amazon Bedrock Mantle provider", () => {
})
expect(responses).toMatchObject({
route: "bedrock-mantle-responses",
protocol: "open-responses",
protocol: "openai-responses",
body: { model: "openai.gpt-oss-120b", store: false },
})
expect(provider.model("openai.gpt-oss-120b").route.providerMetadataKey).toBe("mantle")
@@ -178,7 +178,7 @@ describe("Amazon Bedrock Mantle provider", () => {
const recorded = recordedTests({
prefix: "bedrock-mantle",
provider: "amazon-bedrock",
protocol: "open-responses",
protocol: "openai-responses",
requires: ["AWS_BEARER_TOKEN_BEDROCK"],
metadata: { model: "openai.gpt-oss-120b" },
})
@@ -23,7 +23,7 @@ it.effect("conversation lowering excludes generation settings and tool definitio
instructions: "Keep the context",
input: [
{ role: "user", content: [{ type: "input_text", text: "hello" }] },
{ type: "message", role: "assistant", status: "completed", content: [{ type: "output_text", text: "hi" }] },
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "hi" }] },
],
})
}),
@@ -1,120 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent, Message } from "../../src/index.js"
import { OpenAI } from "../../src/providers.js"
import { configure } from "../../src/providers/openai-compatible-responses.js"
import { compileRequest, LLMClient } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
for (const model of [
OpenAI.configure({ apiKey: "test-key" }).responses("example-model"),
configure({ apiKey: "test-key", baseURL: "https://responses.example.test/v1" }).model("example-model"),
]) {
describe(`${model.route.protocol} message replay`, () => {
const key = model.route.providerMetadataKey ?? "openresponses"
it.effect("marks assistant text completed regardless of stored status", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
...[undefined, "in_progress", "incomplete", "completed"].map((status, index) =>
Message.make({
role: "assistant",
providerMetadata: { [key]: { status } },
content: [
{
type: "text",
text: `Saved ${index}`,
providerMetadata: { [key]: { itemId: `msg_${index}`, phase: "commentary", status } },
},
{
type: "text",
text: `Final ${index}`,
providerMetadata: { [key]: { itemId: `msg_final_${index}`, phase: "final_answer", status } },
},
],
}),
),
Message.make({
role: "user",
content: [{ type: "text", text: "Continue" }],
providerMetadata: { [key]: { status: "incomplete" } },
}),
],
}),
)
expect(prepared.body.input).toEqual([
...[0, 1, 2, 3].flatMap((index) => [
{
type: "message",
role: "assistant",
id: `msg_${index}`,
phase: "commentary",
status: "completed",
content: [{ type: "output_text", text: `Saved ${index}` }],
},
{
type: "message",
role: "assistant",
id: `msg_final_${index}`,
phase: "final_answer",
status: "completed",
content: [{ type: "output_text", text: `Final ${index}` }],
},
]),
{ role: "user", status: "incomplete", content: [{ type: "input_text", text: "Continue" }] },
])
}),
)
it.effect("replays truncated text as completed while retaining the response finish reason", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Respond" })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
item: { type: "message", id: "msg_partial", status: "in_progress" },
},
{ type: "response.output_text.delta", item_id: "msg_partial", delta: "The next step is" },
{
type: "response.output_item.done",
item: {
type: "message",
id: "msg_partial",
status: "incomplete",
content: [{ type: "output_text", text: "The next step is" }],
},
},
{
type: "response.incomplete",
response: { status: "incomplete", incomplete_details: { reason: "max_output_tokens" } },
},
),
),
),
)
expect(response.finishReason.normalized).toBe("length")
expect(response.events.filter(LLMEvent.is.textEnd)).toHaveLength(1)
const prepared = yield* compileRequest(
LLM.request({ model, messages: [response.message, Message.user("Continue")] }),
)
expect(prepared.body.input).toEqual([
{
type: "message",
role: "assistant",
id: "msg_partial",
status: "completed",
content: [{ type: "output_text", text: "The next step is" }],
},
{ role: "user", content: [{ type: "input_text", text: "Continue" }] },
])
}),
)
})
}
@@ -91,7 +91,7 @@ describe("Open Responses-compatible route", () => {
expect(prepared.body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
{ role: "developer", content: "Operator update." },
{ type: "message", role: "assistant", status: "completed", content: [{ type: "output_text", text: "After." }] },
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "After." }] },
])
}),
)
@@ -299,27 +299,23 @@ describe("Open Responses-compatible route", () => {
type: "message",
id: "history_1",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "Kept." }],
},
{
type: "message",
id: `history_${"a".repeat(64)}`,
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "Long." }],
},
{
type: "message",
id: "provider_value/with+symbols",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "Opaque." }],
},
{
type: "message",
role: "assistant",
status: "completed",
content: [
{ type: "output_text", text: "No suffix." },
{ type: "output_text", text: "No prefix." },
@@ -860,7 +856,6 @@ describe("Open Responses-compatible route", () => {
type: "message",
id: "msg_refusal",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "I can't help with that." }],
},
])
@@ -171,7 +171,7 @@ describe("OpenAI Responses WebSocket recorded", () => {
instructions: "Follow the user's exact reply instruction.",
input: [
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Alpha." }] },
{ role: "assistant", status: "completed", content: [{ type: "output_text", text: "Alpha." }] },
{ role: "assistant", content: [{ type: "output_text", text: "Alpha." }] },
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Beta." }] },
],
})
@@ -208,7 +208,7 @@ describe("OpenAI Responses WebSocket recorded", () => {
instructions: "Follow the user's exact reply instruction.",
input: [
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Ready." }] },
{ role: "assistant", status: "completed", content: [{ type: "output_text", text: "Ready." }] },
{ role: "assistant", content: [{ type: "output_text", text: "Ready." }] },
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Recovered." }] },
],
})
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { ConfigProvider, Effect, Layer, Ref, Schema, Stream } from "effect"
import { ConfigProvider, Effect, Layer, Ref, Stream } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import {
LLM,
@@ -30,7 +30,6 @@ import * as Azure from "../../src/providers/azure.js"
import * as OpenAI from "../../src/providers/openai.js"
import * as XAI from "../../src/providers/xai.js"
import * as OpenAIResponses from "../../src/protocols/openai-responses.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { OpenResponsesContinuation } from "../../src/protocols/open-responses-continuation.js"
import * as ProviderShared from "../../src/protocols/shared.js"
import { continuationRequest, nativeOpenAIResponsesContinuation } from "../continuation-scenarios.js"
@@ -70,34 +69,14 @@ const baseChannelDriver = (message: string): WebSocketChannelDriver => ({
},
})
/** Classifies error frames the way the production channel does, so recovery can read the canonical reason. */
const classifyingChannelDriver = (message: string): WebSocketChannelDriver => {
const base = baseChannelDriver(message)
const decodeEvent = Schema.decodeUnknownSync(OpenResponses.protocol.stream.event)
return {
...base,
observe: (create, frame) =>
base.observe(create, frame).pipe(
Effect.map((observation) =>
observation.type === "provider-failure"
? {
...observation,
error: OpenResponses.providerFailure(decodeEvent(frame), "stream error", frame),
}
: observation,
),
),
}
}
const continuationDriver = (request: Readonly<Record<string, unknown>>, base = baseChannelDriver) => {
const continuationDriver = (request: Readonly<Record<string, unknown>>) => {
const message = ProviderShared.encodeJson(request)
return OpenResponsesContinuation.driver({
id: "openai-responses",
name: "OpenAI Responses",
request,
message,
base: base(message),
base: baseChannelDriver(message),
})
}
@@ -406,7 +385,7 @@ describe("OpenAI Responses route", () => {
expect(prepared.body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
{ role: "developer", content: "Operator update." },
{ type: "message", role: "assistant", status: "completed", content: [{ type: "output_text", text: "After." }] },
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "After." }] },
])
}),
)
@@ -581,54 +560,52 @@ describe("OpenAI Responses route", () => {
)
it.effect("continues a streamed tool call with only the new tool output", () =>
Effect.forEach([undefined, []], (output) =>
Effect.gen(function* () {
const firstRequest = {
type: "response.create",
model: "gpt-5.2",
store: false,
input: [{ role: "user", content: [{ type: "input_text", text: "Weather?" }] }],
}
const first = continuationDriver(firstRequest)
const firstCreate = yield* first.create(undefined)
Effect.gen(function* () {
const firstRequest = {
type: "response.create",
model: "gpt-5.2",
store: false,
input: [{ role: "user", content: [{ type: "input_text", text: "Weather?" }] }],
}
const first = continuationDriver(firstRequest)
const firstCreate = yield* first.create(undefined)
yield* first.observe(
firstCreate,
ProviderShared.encodeJson({
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_1",
status: "completed",
call_id: "call_1",
name: "weather",
arguments: '{ "city": "Paris" }',
},
}),
)
const saved = checkpoint(
yield* first.observe(
firstCreate,
ProviderShared.encodeJson({
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_1",
status: "completed",
call_id: "call_1",
name: "weather",
arguments: '{ "city": "Paris" }',
},
}),
)
const saved = checkpoint(
yield* first.observe(
firstCreate,
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1", output } }),
),
)
const second = continuationDriver({
...firstRequest,
input: [
...firstRequest.input,
{ type: "function_call", call_id: "call_1", name: "weather", arguments: '{"city":"Paris"}' },
{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' },
],
})
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
),
)
const second = continuationDriver({
...firstRequest,
input: [
...firstRequest.input,
{ type: "function_call", call_id: "call_1", name: "weather", arguments: '{"city":"Paris"}' },
{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' },
],
})
const create = yield* second.create(saved)
const create = yield* second.create(saved)
expect(create.mode).toBe("incremental")
expect(ProviderShared.decodeJson(create.message)).toMatchObject({
previous_response_id: "resp_1",
input: [{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' }],
})
}),
),
expect(create.mode).toBe("incremental")
expect(ProviderShared.decodeJson(create.message)).toMatchObject({
previous_response_id: "resp_1",
input: [{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' }],
})
}),
)
it.effect("continues a tool call from authoritative completed response output", () =>
@@ -682,47 +659,45 @@ describe("OpenAI Responses route", () => {
)
it.effect("continues a promoted steer after assistant output with response-only text metadata", () =>
Effect.forEach([undefined, []], (output) =>
Effect.gen(function* () {
const firstInput = [{ role: "user", content: [{ type: "input_text", text: "First" }] }]
const first = continuationDriver({ type: "response.create", model: "gpt-5.2", store: false, input: firstInput })
const create = yield* first.create(undefined)
Effect.gen(function* () {
const firstInput = [{ role: "user", content: [{ type: "input_text", text: "First" }] }]
const first = continuationDriver({ type: "response.create", model: "gpt-5.2", store: false, input: firstInput })
const create = yield* first.create(undefined)
yield* first.observe(
create,
ProviderShared.encodeJson({
type: "response.output_item.done",
item: {
type: "message",
id: "msg_1",
status: "completed",
role: "assistant",
content: [{ type: "output_text", text: "Hello", annotations: [], logprobs: [] }],
},
}),
)
const saved = checkpoint(
yield* first.observe(
create,
ProviderShared.encodeJson({
type: "response.output_item.done",
item: {
type: "message",
id: "msg_1",
status: "completed",
role: "assistant",
content: [{ type: "output_text", text: "Hello", annotations: [], logprobs: [] }],
},
}),
)
const saved = checkpoint(
yield* first.observe(
create,
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1", output } }),
),
)
const steer = { role: "user", content: [{ type: "input_text", text: "Actually, be brief" }] }
const next = continuationDriver({
type: "response.create",
model: "gpt-5.2",
store: false,
input: [...firstInput, { role: "assistant", content: [{ type: "output_text", text: "Hello" }] }, steer],
})
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
),
)
const steer = { role: "user", content: [{ type: "input_text", text: "Actually, be brief" }] }
const next = continuationDriver({
type: "response.create",
model: "gpt-5.2",
store: false,
input: [...firstInput, { role: "assistant", content: [{ type: "output_text", text: "Hello" }] }, steer],
})
const continued = yield* next.create(saved)
const continued = yield* next.create(saved)
expect(continued.mode).toBe("incremental")
expect(ProviderShared.decodeJson(continued.message)).toMatchObject({
previous_response_id: "resp_1",
input: [steer],
})
}),
),
expect(continued.mode).toBe("incremental")
expect(ProviderShared.decodeJson(continued.message)).toMatchObject({
previous_response_id: "resp_1",
input: [steer],
})
}),
)
it.effect("continues streamed reasoning when completion re-encrypts the same item", () =>
@@ -877,53 +852,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("retries an incremental send in full when the provider rejects it without a code", () =>
Effect.gen(function* () {
const firstRequest = {
type: "response.create",
model: "gpt-5.2",
store: false,
input: [{ role: "user", content: [{ type: "input_text", text: "First" }] }],
}
const first = continuationDriver(firstRequest, classifyingChannelDriver)
const saved = checkpoint(
yield* first.observe(
yield* first.create(undefined),
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
),
)
const second = continuationDriver(
{
...firstRequest,
input: [...firstRequest.input, { role: "user", content: [{ type: "input_text", text: "Second" }] }],
},
classifyingChannelDriver,
)
// Codex reports a stale previous_response_id as a plain invalid_request_error.
const stale = ProviderShared.encodeJson({
type: "error",
error: { type: "invalid_request_error", message: "Invalid `previous_response_id`." },
})
const incremental = yield* second.create(saved)
expect(incremental.mode).toBe("incremental")
expect(yield* second.observe(incremental, stale)).toMatchObject({ type: "rejected", recovery: "retry-full" })
// A full send has no continuation to blame, so the same error stays a provider failure.
const full = yield* second.create(undefined)
expect(yield* second.observe(full, stale)).toMatchObject({ type: "provider-failure" })
// A classified failure keeps its runner-owned recovery instead of resending the whole context.
const overflow = ProviderShared.encodeJson({
type: "error",
error: { type: "invalid_request_error", code: "context_length_exceeded", message: "Too long" },
})
expect(yield* second.observe(yield* second.create(saved), overflow)).toMatchObject({
type: "provider-failure",
error: { reason: { _tag: "InvalidRequest", classification: "context-overflow" } },
})
}),
)
it.effect("builds WebSocket and HTTP fallback from the same final request", () =>
Effect.gen(function* () {
const attempts = yield* Ref.make(0)
@@ -2122,7 +2050,6 @@ describe("OpenAI Responses route", () => {
type: "message",
id: "msg_refusal",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "I can't help with that." }],
phase: "final_answer",
},
@@ -2205,7 +2132,6 @@ describe("OpenAI Responses route", () => {
type: "message",
id: "msg_commentary",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "Checking." }],
phase: "commentary",
},
@@ -2213,7 +2139,6 @@ describe("OpenAI Responses route", () => {
type: "message",
id: "msg_final",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "Finished." }],
phase: "final_answer",
},
@@ -2221,7 +2146,6 @@ describe("OpenAI Responses route", () => {
type: "message",
id: "msg_null",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "Unclassified." }],
phase: null,
},
@@ -3352,19 +3276,14 @@ describe("OpenAI Responses route", () => {
)
expect(prepared.body.input).toEqual([
{
type: "message",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "Before." }],
},
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "Before." }] },
{
type: "reasoning",
id: "rs_1",
encrypted_content: "encrypted-state",
summary: [{ type: "summary_text", text: "Checked order." }],
},
{ type: "message", role: "assistant", status: "completed", content: [{ type: "output_text", text: "After." }] },
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "After." }] },
])
}),
)
@@ -3628,14 +3547,12 @@ describe("OpenAI Responses route", () => {
type: "message",
id: "history_1",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "Hello" }],
},
{
type: "message",
id: `message_${"a".repeat(64)}`,
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "World" }],
},
{
@@ -1,103 +0,0 @@
import { DialogProvider } from "@opencode/ui/context/dialog"
import { Browser } from "@opencode/plugin-browser/rpc"
import { For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { render } from "solid-js/web"
import { LanguageProvider, UiI18nBridge } from "../src/runtime/i18n/language"
import type { BrowserPaneLayout, BrowserPaneRegistration } from "../src/runtime/platform/browser-pane"
import type { createSessionBrowser } from "../src/session/browser/model"
import { SessionBrowserPane } from "../src/session/browser/pane"
export function mountBrowserPane() {
const host = document.createElement("main")
host.dataset.testid = "browser-pane-fixture"
host.style.cssText = "position:fixed;inset:0;z-index:1000;background:#181818;color:#eee;padding:24px"
document.body.appendChild(host)
function Fixture() {
const [store, setStore] = createStore({
session: "Alpha",
mounted: true,
visible: true,
layouts: {} as Record<string, BrowserPaneLayout | undefined>,
})
const tabs = ["Alpha", "Beta"].map((name) => ({
id: Browser.TabID.make(`tab_${name === "Alpha" ? "11111111" : "22222222"}-1111-1111-1111-111111111111`),
title: name,
url: `https://${name.toLowerCase()}.example/`,
loading: false,
canGoBack: false,
canGoForward: false,
generation: 0,
}))
// Record the native boundary per registration: hiding Beta cannot hide Alpha's view.
const registrations = new Map<string, BrowserPaneRegistration>(
tabs.map((tab) => [
tab.title,
{
setLayout: (layout) => setStore("layouts", tab.title, layout),
command: async () => undefined,
close: () => undefined,
},
]),
)
const browser: ReturnType<typeof createSessionBrowser> = {
available: () => true,
attached: () => !!registrations.get(store.session),
opened: () => !!registrations.get(store.session),
state: () => ({ tabs: tabs.filter((tab) => tab.title === store.session), focusedTabID: null }),
tabs: () => tabs.filter((tab) => tab.title === store.session),
active: () => tabs.find((tab) => tab.title === store.session) ?? tabs[0],
registration: () => registrations.get(store.session),
error: () => undefined,
suspended: () => false,
close: () => undefined,
open: () => undefined,
command: () => undefined,
}
return (
<>
<h1 style={{ "font-size": "24px", "margin-bottom": "16px" }}>Browser pane lifecycle</h1>
<p>Selected session: {store.session}</p>
<nav style={{ display: "flex", gap: "20px", margin: "16px 0" }}>
<For each={["Alpha", "Beta", "Empty"]}>
{(name) => <button onClick={() => setStore({ session: name, mounted: name !== "Empty" })}>{name}</button>}
</For>
<button onClick={() => setStore("mounted", false)}>Unmount pane</button>
<button onClick={() => setStore("visible", (visible) => !visible)}>Toggle Review tab</button>
</nav>
<div style={{ width: "640px", height: "360px", border: "1px solid #555" }}>
<Show when={store.mounted}>
<SessionBrowserPane browser={browser} visible={store.visible} />
</Show>
</div>
<h2 style={{ "font-size": "18px", margin: "20px 0 12px" }}>Native layout recorder</h2>
<p>The desktop boundary keeps each session's page visible until its registration is hidden.</p>
<For each={tabs}>
{(tab) => (
<div
data-testid={`native-${tab.title}`}
data-visible={!!store.layouts[tab.title]?.visible}
style={{ padding: "12px", margin: "8px 0", border: "1px solid #555" }}
>
{tab.title}: {store.layouts[tab.title]?.visible ? "visible" : "hidden"}
</div>
)}
</For>
</>
)
}
return render(
() => (
<LanguageProvider locale="en">
<UiI18nBridge>
<DialogProvider>
<Fixture />
</DialogProvider>
</UiI18nBridge>
</LanguageProvider>
),
host,
)
}
@@ -1,44 +0,0 @@
import { fileURLToPath } from "node:url"
import { expect, story } from "../../storybook/playwright/story"
const fixture = `/@fs/${fileURLToPath(new URL("./browser-pane.fixture.tsx", import.meta.url)).replaceAll("\\", "/")}`
story.beforeEach(async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--mixed-attachments")
await expect(component.getByRole("textbox", { name: "Prompt", exact: true })).toBeVisible()
await page.evaluate(async (fixture) => {
const { mountBrowserPane } = await import(fixture)
mountBrowserPane()
}, fixture)
await expect(page.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
})
story("hides the previous registration when the mounted pane switches sessions", async ({ page }, testInfo) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Beta", exact: true }).click()
await expect(root.getByTestId("native-Beta")).toHaveAttribute("data-visible", "true")
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
await page.screenshot({ path: testInfo.outputPath("session-switch.png") })
await root.getByRole("button", { name: "Alpha", exact: true }).click()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await expect(root.getByTestId("native-Beta")).toHaveAttribute("data-visible", "false")
})
story("hides the outgoing browser when the destination has no browser pane", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Empty", exact: true }).click()
await expect(root.locator("#browser-panel")).toHaveCount(0)
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
await root.getByRole("button", { name: "Alpha", exact: true }).click()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
})
story("hides and restores the same registration for Review tabs and unmount", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Toggle Review tab", exact: true }).click()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
await root.getByRole("button", { name: "Toggle Review tab", exact: true }).click()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await root.getByRole("button", { name: "Unmount pane", exact: true }).click()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
})
@@ -1,27 +0,0 @@
# Session-export load benchmark
Replay an exported session against a production app build. The two cases compare the default Compact preset with every category ungrouped and details still collapsed.
From `packages/app` in PowerShell:
```powershell
$env:PLAYWRIGHT_BUILD = '1'
$env:PLAYWRIGHT_BASE_URL = 'http://127.0.0.1:4398' # Existing production preview
$env:LAGGY_SESSION_FILE = 'C:\path\session.json'
$env:LAGGY_SESSION_OUTPUT = 'C:\tmp\opencode\session-load'
$env:LAGGY_SESSION_HISTORY = 'paged' # Or 'full' to supply all exported history
bun x playwright test --config e2e/performance/playwright.config.ts timeline/laggy-session-benchmark.spec.ts --repeat-each=20 --workers=1 --retries=0
bun e2e/performance/timeline/laggy-session-report.ts $env:LAGGY_SESSION_OUTPUT
```
Each test uses a fresh browser context and measures one cold load, switches back to the source session, then measures one warm load. Repetitions therefore interleave `cold → warm` pairs rather than collecting separate cold and warm batches. There are no discarded warm-up switches. The warm member of every pair must issue zero message requests. Each pair is saved in a separate JSON file; compare paired differences as well as the cold and warm distributions when system load varies.
The report writes `summary.json` and prints the median, p95, maximum, and median paired cold-minus-warm difference. It rejects incomplete or cold-only records instead of mixing them into paired results.
`--repeat-each=20` collects 20 pairs per grouping mode. `LAGGY_SESSION_COLD_ONLY=1` remains available for focused cold profiling. Screenshots are taken after a pair finishes, not between its measurements.
The app shell, source session, model control, and fonts are ready before the timed action. These measurements cover session entry, not application startup. `firstCorrectObservedMs` begins at mousedown and ends when the destination is visible at its expected bottom position, including Compact's automatic history fill. `stableObservedMs` includes three-observation confirmation and must not be treated as additional rendering time.
Set `OPENCODE_PERFORMANCE_TRACE_DIR` for Chrome traces. `LAGGY_TRACE_ITERATION=0` traces the cold load; the default (`1`) traces the warm member of the pair. Profile separately from timing runs.
`LAGGY_HTTP=1` disables route interception for an external HTTP replay server containing the same export and source fixture. Keep direct HTTP and Playwright-routed cold series separate: routing adds transport overhead. Raw samples, mode settings, viewport, browser version, and screenshots are retained in the output directory. The export itself is not copied into the repository.
@@ -1,227 +0,0 @@
import { readFileSync, mkdirSync, writeFileSync } from "node:fs"
import type { SessionMessageInfo } from "@opencode/client/promise"
import { base64Encode } from "@opencode/util/encode"
import { timelineCategories, timelinePresets } from "@opencode/session-ui/timeline/detail"
import { mockOpenCodeServer } from "../../utils/mock-server"
import { expectSessionTitle } from "../../utils/waits"
import { benchmark, expect } from "../benchmark"
import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
import { stressSessionHref } from "./timeline-test-helpers"
import { startChromeTrace } from "../chrome-trace"
const file = process.env.LAGGY_SESSION_FILE
const session = file
? (JSON.parse(readFileSync(file, "utf8")) as {
info: {
id: string
projectID: string
title: string
model?: { id: string; providerID: string }
location: { directory: string }
time: { created: number; updated: number }
}
messages: SessionMessageInfo[]
})
: undefined
const sourceID = "ses_laggy_benchmark_source"
const sourceMessageID = "msg_laggy_benchmark_source"
const history = process.env.LAGGY_SESSION_HISTORY ?? "full"
const viewport = { width: 1440, height: 900 }
benchmark.use({ viewport, video: "off", trace: "off", serviceWorkers: "block", traceScope: "interaction" })
for (const mode of ["compact", "ungrouped"] as const) {
benchmark(`laggy session: ${mode}`, async ({ page, report }, testInfo) => {
benchmark.skip(!session, "Set LAGGY_SESSION_FILE to a session export")
if (!session) return
const output = process.env.LAGGY_SESSION_OUTPUT ?? testInfo.outputPath("session-load")
const model = session.info.model ?? { id: "benchmark-model", providerID: "benchmark" }
const lastID = session.messages.findLast((message) => message.type === "user")!.id
const lastText = session.messages.findLast(
(message) =>
message.type === "assistant" && message.content.some((part) => part.type === "text" && part.text.trim()),
)!
benchmark.setTimeout(Number(process.env.LAGGY_SESSION_TIMEOUT ?? 180_000))
const requests: string[] = []
const errors: string[] = []
page.on("pageerror", (error) => errors.push(error.message))
if (process.env.LAGGY_HTTP === "1")
page.on("request", (request) => {
const match = new URL(request.url()).pathname.match(/^\/api\/session\/([^/]+)\/message$/)
if (request.method() === "GET" && match) requests.push(decodeURIComponent(match[1]))
})
const detail = Object.fromEntries(
timelineCategories.map((category) => [
category,
{
...timelinePresets[2].value[category],
placement: mode === "compact" ? "grouped" : "separate",
},
]),
)
const directory = session.info.location.directory
if (process.env.LAGGY_HTTP !== "1")
await mockOpenCodeServer(page, {
directory,
project: {
id: session.info.projectID,
worktree: directory,
vcs: "git",
name: "session-benchmark",
time: session.info.time,
sandboxes: [],
},
provider: {
all: [
{
id: model.providerID,
name: model.providerID,
models: { [model.id]: { id: model.id, name: model.id, limit: { context: 1_000_000 } } },
},
],
connected: [model.providerID],
default: { providerID: model.providerID, modelID: model.id },
},
sessions: [session.info, { ...session.info, id: sourceID, title: "Benchmark source" }],
pageMessages: (id, limit, before) => {
if (id !== session.info.id)
return {
items: [
{
id: sourceMessageID,
type: "user",
text: "Benchmark source",
time: { created: session.info.time.created },
},
],
}
if (history === "full") return { items: session.messages }
const end = before ? session.messages.findIndex((message) => message.id === before) : session.messages.length
const start = Math.max(0, end - limit)
return {
items: session.messages.slice(start, end),
cursor: start > 0 ? session.messages[start].id : undefined,
}
},
onMessages: (request) => {
if (request.phase === "start") requests.push(request.sessionID)
},
})
await page.addInitScript(
({ detail, directory, server, sessionIDs, dirBase64 }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { timelineDetail: detail } }))
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify(sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64, sessionId }))),
)
},
{
detail,
directory,
server: process.env.PLAYWRIGHT_BASE_URL!,
sessionIDs: [sourceID, session.info.id],
dirBase64: base64Encode(directory),
},
)
await page.goto(stressSessionHref(sourceID))
await expectSessionTitle(page, "Benchmark source")
await expect(page.locator('[data-slot="user-message-text"]')).toHaveText("Benchmark source")
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
await expect(page.getByRole("button", { name: model.id, exact: true })).toBeVisible()
await page.evaluate(() => document.fonts.ready.then(() => undefined))
expect(requests).toEqual([sourceID])
const startedAt = new Date().toISOString()
const samples = []
const phases = process.env.LAGGY_SESSION_COLD_ONLY === "1" ? (["cold"] as const) : (["cold", "warm"] as const)
for (const [iteration, phase] of phases.entries()) {
const before = requests.length
const stopTrace =
iteration === Number(process.env.LAGGY_TRACE_ITERATION ?? 1)
? await startChromeTrace(page, `laggy-${history}-${mode}`)
: undefined
const result = await measureSessionSwitch(page, {
destinationIDs: session.messages.map((message) => message.id),
sourceIDs: [sourceMessageID],
lastID,
requiredPartID: history === "paged" && mode === "compact" ? `${lastText.id}:text:0` : undefined,
requireBottomAnchor: true,
href: stressSessionHref(session.info.id),
switch: async () => {
await page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(session.info.id)}"]`).click()
},
})
await stopTrace?.()
await expectSessionTitle(page, session.info.title)
if (history === "full" || mode === "ungrouped") await waitForStableTimeline(page, lastID)
await expect(
page.locator('[data-timeline-key] [data-component="markdown"]:not([data-markdown-ready])'),
).toHaveCount(0)
expect(result.firstCorrectObservedMs).not.toBeNull()
expect(result.stableObservedMs).not.toBeNull()
if (phase === "warm") expect(requests.length - before).toBe(0)
samples.push({
iteration,
phase,
messageRequests: requests.length - before,
messageResources: await page.evaluate((sessionID) => {
const start = performance.getEntriesByName("session-switch:start").at(-1)!.startTime
return (performance.getEntriesByType("resource") as PerformanceResourceTiming[])
.filter(
(entry) =>
entry.startTime >= start && new URL(entry.name).pathname === `/api/session/${sessionID}/message`,
)
.map((entry) => ({
limit: Number(new URL(entry.name).searchParams.get("limit")),
startMs: entry.startTime - start,
durationMs: entry.duration,
transferBytes: entry.transferSize,
}))
}, session.info.id),
...result,
})
if (iteration === phases.length - 1) {
mkdirSync(output, { recursive: true })
if (testInfo.repeatEachIndex === 0) await page.screenshot({ path: `${output}/${mode}.png` })
break
}
await page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(sourceID)}"]`).click()
await expectSessionTitle(page, "Benchmark source")
await expect(page.locator('[data-slot="user-message-text"]')).toHaveText("Benchmark source")
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
}
expect(errors).toEqual([])
const result = {
pair: testInfo.repeatEachIndex,
startedAt,
mode,
history,
file,
messages: session.messages.length,
viewport,
browser: page.context().browser()!.version(),
detail,
samples,
}
writeFileSync(`${output}/${mode}-${testInfo.repeatEachIndex}.json`, JSON.stringify(result, null, 2))
report(
{ samples },
{
mode,
sampling: "cold/warm pair in one browser context",
pair: testInfo.repeatEachIndex,
messages: session.messages.length,
viewport,
data: history === "full" ? "full exported history" : "paginated exported history",
transport: process.env.LAGGY_HTTP === "1" ? "http" : "playwright-route",
inputEvent: "mousedown",
},
)
})
}
@@ -1,63 +0,0 @@
export {}
type Pair = {
mode: "compact" | "ungrouped"
samples: { phase: string; firstCorrectObservedMs: number | null; messageRequests: number }[]
}
const directory = Bun.argv[2]
if (!directory) throw new Error("Pass the directory containing session-load pairs")
const pairs = await Promise.all(
[...new Bun.Glob("{compact,ungrouped}-*.json").scanSync(directory)].map(async (file) => {
const pair = (await Bun.file(`${directory}/${file}`).json()) as Pair
const cold = pair.samples.find((sample) => sample.phase === "cold")
const warm = pair.samples.find((sample) => sample.phase === "warm")
if (cold?.firstCorrectObservedMs == null || warm?.firstCorrectObservedMs == null)
throw new Error(`Expected a completed cold/warm pair in ${file}`)
return {
mode: pair.mode,
cold: cold.firstCorrectObservedMs,
warm: warm.firstCorrectObservedMs,
requests: warm.messageRequests,
}
}),
)
if (!pairs.length) throw new Error(`No session-load pairs found in ${directory}`)
const result = ["compact", "ungrouped"].flatMap((mode) => {
const selected = pairs.filter((pair) => pair.mode === mode)
if (!selected.length) return []
return [
{
mode,
cold: { ...stats(selected.map((pair) => pair.cold)), over50ms: selected.filter((pair) => pair.cold > 50).length },
warm: { ...stats(selected.map((pair) => pair.warm)), over50ms: selected.filter((pair) => pair.warm > 50).length },
pairedColdMinusWarm: stats(selected.map((pair) => pair.cold - pair.warm)),
messageRequestsDuringWarm: selected.reduce((total, pair) => total + pair.requests, 0),
},
]
})
await Bun.write(`${directory}/summary.json`, JSON.stringify(result, null, 2))
console.table(
result.map((row) => ({
mode: row.mode,
pairs: row.cold.n,
coldMedianMs: Math.round(row.cold.median * 10) / 10,
coldP95Ms: Math.round(row.cold.p95 * 10) / 10,
warmMedianMs: Math.round(row.warm.median * 10) / 10,
warmP95Ms: Math.round(row.warm.p95 * 10) / 10,
warmMaxMs: Math.round(row.warm.max * 10) / 10,
pairedDifferenceMs: Math.round(row.pairedColdMinusWarm.median * 10) / 10,
})),
)
function stats(values: number[]) {
const sorted = values.toSorted((left, right) => left - right)
return {
n: sorted.length,
median: (sorted[Math.floor((sorted.length - 1) / 2)] + sorted[Math.floor(sorted.length / 2)]) / 2,
p95: sorted[Math.ceil(sorted.length * 0.95) - 1],
min: sorted[0],
max: sorted.at(-1)!,
}
}
@@ -16,11 +16,6 @@ test("status drawer dismisses and reopens after button, backdrop, Escape, and dr
await more.click()
await page.getByRole("menuitem", { name: "Status", exact: true }).click()
await expect(drawer.getByRole("tab", { name: "MCP", exact: true })).toBeVisible()
// Corvu starts opening after paint; the transition flag is also absent
// before that callback. Wait for the open position before dismissing.
await expect
.poll(() => drawer.evaluate((element) => new DOMMatrixReadOnly(getComputedStyle(element).transform).m42))
.toBe(0)
await expect(drawer).not.toHaveAttribute("data-transitioning")
if (dismissal === "button") await drawer.getByRole("button", { name: "Close", exact: true }).click()
if (dismissal === "backdrop") await overlay.click({ position: { x: 10, y: 10 } })
@@ -22,10 +22,6 @@ test("selects a base branch for a new workspace", async ({ page }) => {
pageMessages: () => ({ items: [] }),
vcsBranches: ["feature/api", "main", "origin/release"],
})
await page.route("**/api/vcs/branches?*", (route) => {
if (new URL(route.request().url()).searchParams.get("search") !== "feature") return route.fallback()
return route.fulfill({ json: { location: { directory }, data: ["feature/api"] } })
})
await page.addInitScript(
({ directory, draftID, server }) => {
localStorage.setItem(
@@ -48,28 +44,10 @@ test("selects a base branch for a new workspace", async ({ page }) => {
await page.getByRole("button", { name: "Local", exact: true }).click()
await page.getByRole("menuitem", { name: "New worktree", exact: true }).click()
await page.getByRole("button", { name: "from main", exact: true }).click()
const search = page.getByRole("textbox", { name: "Search branches", exact: true })
await expect(search).toBeFocused()
await page.keyboard.type("feature")
await expect(search).toHaveValue("feature")
await expect(page.getByRole("menuitemradio")).toHaveText(["feature/api"])
await expect(search).toBeFocused()
await page.getByRole("menuitemradio", { name: "feature/api", exact: true }).click()
const selected = page.getByRole("button", { name: "from feature/api", exact: true })
await expect(selected).toBeVisible()
await selected.click()
await expect(search).toBeFocused()
await expect(search).toHaveValue("")
await expect(page.getByRole("menuitemradio", { name: "feature/api", exact: true })).toBeChecked()
await page.keyboard.press("Escape")
await expect(selected).toBeFocused()
await page.keyboard.press("Enter")
await expect(search).toBeFocused()
await page.keyboard.type("feature")
await expect(search).toHaveValue("feature")
await expect(page.getByRole("menuitemradio")).toHaveText(["feature/api"])
await page.getByRole("button", { name: "Clear", exact: true }).click()
await expect(search).toHaveValue("")
await expect(page.getByRole("menuitemradio")).toHaveText(["feature/api", "main", "origin/release"])
})
@@ -238,9 +238,16 @@ async function expectHeaderClearOfToggle(page: Page, toggle: Locator, progress:
})
const chatBounds = chat.getBoundingClientRect()
const panelBounds = document.querySelector("#review-panel")!.getBoundingClientRect()
const summaryBounds = document
.querySelector('[data-session-title] button[aria-label="Session details"]')!
.getBoundingClientRect()
return {
row: row.getBoundingClientRect().width,
panelWidth: panelBounds.width,
timelineControlInset:
getComputedStyle(row).direction === "rtl"
? summaryBounds.left - chatBounds.left
: chatBounds.right - summaryBounds.right,
gap:
getComputedStyle(row).direction === "rtl"
? chatBounds.left - panelBounds.right
@@ -250,6 +257,8 @@ async function expectHeaderClearOfToggle(page: Page, toggle: Locator, progress:
}
}, progress)
expect(geometry.gap).toBeCloseTo(8, 1)
// Reserve the fixed toggle's 28px width, the 8px control gap, and the 12px header inset.
expect(geometry.timelineControlInset).toBeCloseTo(48, 1)
if (geometry.panelWidth > 0) expect(Math.abs(geometry.row - geometry.panels)).toBeLessThanOrEqual(1)
if (progress === 0.25) {
expect(geometry.contentOpacity).toBeGreaterThan(0)
@@ -1,315 +0,0 @@
import { expect, test } from "@playwright/test"
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode/client/promise"
import { timelinePresets } from "@opencode/session-ui/timeline/detail"
import { mockOpenCodeServer } from "../utils/mock-server"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { installStressSessionTabs, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
import { waitForStableTimeline } from "../performance/timeline/session-tab-switch-probe"
test.use({ viewport: { width: 1440, height: 900 }, serviceWorkers: "block" })
test("recovers from a failed cold history load when another session is selected", async ({ page }) => {
await mockOpenCodeServer(page, {
...fixture,
pageMessages: (id) => ({
items: [{ id: `msg_${id}`, type: "user", text: `History for ${id}`, time: { created: 1 } }],
}),
})
await page.route(`**/api/session/${fixture.targetID}/message?*`, (route) =>
route.fulfill({ status: 500, json: { message: "History unavailable" } }),
)
await installStressSessionTabs(page)
await page.goto(stressSessionHref(fixture.sourceID))
await expect(page.getByText(`History for ${fixture.sourceID}`, { exact: true })).toBeVisible()
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
await expect(page.getByRole("heading", { name: "Something went wrong", exact: true })).toBeVisible()
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.sourceID)}"]`).click()
await expect(page.getByText(`History for ${fixture.sourceID}`, { exact: true })).toBeVisible()
await expect(page.getByRole("heading", { name: "Something went wrong", exact: true })).toHaveCount(0)
})
test("focuses Find in the selected cached timeline", async ({ page }) => {
await mockOpenCodeServer(page, {
...fixture,
pageMessages: (id) => ({
items: [{ id: `msg_${id}`, type: "user", text: `History for ${id}`, time: { created: 1 } }],
}),
})
await installStressSessionTabs(page)
await page.goto(stressSessionHref(fixture.sourceID))
await expect(page.getByText(`History for ${fixture.sourceID}`, { exact: true })).toBeVisible()
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
await expect(page.getByText(`History for ${fixture.targetID}`, { exact: true })).toBeVisible()
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.sourceID)}"]`).click()
await expect(page.getByText(`History for ${fixture.sourceID}`, { exact: true })).toBeVisible()
await page.keyboard.press("ControlOrMeta+f")
const search = page.locator('[data-component="timeline-search-bar"] input')
await expect(search).toBeFocused()
await page.keyboard.type("History")
await expect(search).toHaveValue("History")
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
await expect(page.getByText(`History for ${fixture.targetID}`, { exact: true })).toBeVisible()
await page.keyboard.press("ControlOrMeta+f")
await expect(search).toBeFocused()
await search.press("Escape")
await expect(search).toHaveCount(0)
})
test("disposes the old workspace's shell while destination history is loading", async ({ page }) => {
const destination = "C:/OpenCode/OtherProject"
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
const reads: string[] = []
const output = { text: "Initial shell output\n" }
await mockOpenCodeServer(page, {
...fixture,
sessions: fixture.sessions.map((session) =>
session.id === fixture.targetID ? { ...session, directory: destination } : session,
),
pageMessages: (id) => ({
items:
id === fixture.sourceID
? ([
{ id: "msg_workspace_source", type: "user", text: "Follow the shell", time: { created: 1 } },
{
id: "msg_workspace_shell",
type: "assistant",
agent: "build",
model: { id: "claude-opus-4-6", providerID: "opencode" },
time: { created: 2 },
content: [
{
type: "tool",
id: "call_workspace_shell",
name: "shell",
time: { created: 2 },
state: {
status: "running",
input: { command: "run checks" },
metadata: { shellID: "sh_workspace_source" },
},
},
],
},
] satisfies SessionMessageInfo[])
: [],
}),
beforeMessagesResponse: async ({ sessionID }) => {
if (sessionID !== fixture.targetID) return
requested.resolve()
await release.promise
},
})
await page.route("**/api/shell/sh_workspace_source/output?*", (route) => {
const url = new URL(route.request().url())
const directory = url.searchParams.get("location[directory]")!
reads.push(directory)
if (directory !== fixture.directory)
return route.fulfill({ status: 404, json: { _tag: "ShellNotFoundError", id: "sh_workspace_source" } })
return route.fulfill({
json: {
location: { directory },
data: {
output: output.text.slice(Number(url.searchParams.get("cursor") ?? 0)),
cursor: output.text.length,
size: output.text.length,
truncated: false,
},
},
})
})
await installStressSessionTabs(page)
await page.addInitScript(
(detail) =>
localStorage.setItem(
"settings.v3",
JSON.stringify({
general: {
timelineDetail: { ...detail, shell: { placement: "separate", details: "expanded" } },
},
}),
),
timelinePresets[2].value,
)
await page.goto(stressSessionHref(fixture.sourceID))
const shell = page.locator('[data-timeline-part-id="call_workspace_shell"]')
await expect(shell.locator('[data-slot="bash-result"]')).toContainText("Initial shell output")
const original = await page.locator("[data-timeline-virtual-content]").elementHandle()
try {
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
await requested.promise
await expect(page.locator("[data-session-title]")).toHaveText(fixture.expected.targetTitle)
output.text += "Output after returning\n"
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.sourceID)}"]`).click()
await expect(shell.locator('[data-slot="bash-result"]')).toContainText("Output after returning")
expect(await original!.evaluate((element) => element.isConnected)).toBe(false)
expect(reads.length).toBeGreaterThan(1)
expect(reads.every((directory) => directory === fixture.directory)).toBe(true)
} finally {
release.resolve()
}
})
test("loads the transcript code font before opening rich history", async ({ page }) => {
const font = page.waitForResponse((response) => /IBMPlexMono-Text[^/]*\.woff2/.test(response.url()))
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
provider: fixture.provider,
sessions: fixture.sessions,
pageMessages: () => ({
items: [{ id: "msg_font_source", type: "user", text: "A transcript with no code", time: { created: 1 } }],
}),
})
await installStressSessionTabs(page)
await page.goto(stressSessionHref(fixture.sourceID))
await expect(page.getByText("A transcript with no code", { exact: true })).toBeVisible()
expect((await font).ok()).toBe(true)
await expect.poll(() => page.evaluate(() => document.fonts.check('440 13px "IBM Plex Mono"'))).toBe(true)
})
test("waits for the requested session's history before constructing its cold timeline", async ({ page }) => {
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
provider: fixture.provider,
sessions: fixture.sessions,
pageMessages: (id) => ({ items: fixture.messages[id] ?? [] }),
beforeMessagesResponse: async ({ sessionID }) => {
if (sessionID !== fixture.targetID) return
requested.resolve()
await release.promise
},
})
await installStressSessionTabs(page)
await page.goto(stressSessionHref(fixture.sourceID))
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
try {
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
await requested.promise
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCount(0)
release.resolve()
await waitForStableTimeline(page, fixture.expected.targetMessageIDs.at(-1)!)
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCount(1)
} finally {
release.resolve()
}
})
for (const grouped of [true, false]) {
test(`restores a ${grouped ? "grouped" : "separate"} timeline after inactive updates and a resize`, async ({
page,
}) => {
const events: OpenCodeEvent[] = []
const messages: Record<string, SessionMessageInfo[]> = Object.fromEntries(
[fixture.sourceID, fixture.targetID].map((id) => [
id,
[
{ id: `msg_user_${id}`, type: "user", text: `Prompt for ${id}`, time: { created: 1 } },
{
id: `msg_assistant_${id}`,
type: "assistant",
agent: "build",
model: { id: "claude-opus-4-6", providerID: "opencode" },
time: { created: 2, completed: 3 },
content: [
{
type: "tool",
id: `tool_${id}`,
name: "shell",
time: { created: 2, completed: 3 },
state: {
status: "completed",
input: { command: `echo ${id}` },
metadata: {},
content: [{ type: "text", text: `Output for ${id}` }],
},
},
{ type: "text", text: `Answer for ${id}` },
],
},
] satisfies SessionMessageInfo[],
]),
)
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
provider: fixture.provider,
sessions: fixture.sessions,
pageMessages: (id) => ({ items: messages[id] ?? [] }),
events: () => events.splice(0),
})
await installStressSessionTabs(page)
await page.addInitScript(
({ grouped, detail }) => {
localStorage.setItem(
"settings.v3",
JSON.stringify({
general: {
timelineDetail: {
...detail,
shell: { placement: grouped ? "grouped" : "separate", details: "collapsed" },
},
},
}),
)
},
{ grouped, detail: timelinePresets[2].value },
)
await page.goto(stressSessionHref(fixture.sourceID))
await expect(page.getByText(`Answer for ${fixture.sourceID}`, { exact: true })).toBeVisible()
if (grouped)
await page
.locator(
'[data-component="collapsed-tool-group"] > [data-component="collapsible"] > [data-slot="collapsible-trigger"]',
)
.click()
const shell = page.locator(`[data-timeline-part-id="tool_${fixture.sourceID}"]`)
const trigger = shell.locator('[data-slot="collapsible-trigger"]')
await trigger.click()
await expect(shell.locator('[data-slot="bash-result"]')).toHaveText(`Output for ${fixture.sourceID}`)
const original = await page.locator("[data-timeline-virtual-content]").elementHandle()
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
await expect(page.getByText(`Answer for ${fixture.targetID}`, { exact: true })).toBeVisible()
await expect(shell).toHaveCount(0)
expect(await original!.evaluate((element) => element.isConnected)).toBe(false)
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCount(1)
events.push({
id: "evt_cached_text",
created: 4,
type: "session.text.ended",
location: { directory: fixture.directory },
durable: { aggregateID: fixture.sourceID, seq: 0, version: 1 },
data: {
sessionID: fixture.sourceID,
assistantMessageID: `msg_assistant_${fixture.sourceID}`,
ordinal: 0,
text: "Updated while inactive",
},
})
await page.setViewportSize({ width: 900, height: 650 })
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.sourceID)}"]`).click()
await expect(page.getByText("Updated while inactive", { exact: true })).toBeVisible()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await expect(shell.locator('[data-slot="bash-result"]')).toHaveText(`Output for ${fixture.sourceID}`)
expect(await original!.evaluate((element) => element.isConnected)).toBe(true)
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCount(1)
await expect
.poll(() =>
page
.locator("[data-timeline-key]")
.evaluateAll((rows) =>
rows.every(
(row) =>
(row.firstElementChild?.getBoundingClientRect().height ?? 0) <= row.getBoundingClientRect().height + 1,
),
),
)
.toBe(true)
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
})
}
@@ -17,7 +17,7 @@ import { mockOpenCodeServer } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport"
import { expectSessionTitle } from "../utils/waits"
const messagePageSize = 40
const messagePageSize = 20
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const messages = Array.from({ length: messagePageSize / 2 + 1 }, (_, index) => {
const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user`
@@ -188,7 +188,7 @@ for (const scenario of scenarios) {
await waitForProbeSamples(page, beforeHistory)
expect(pages).toEqual([
{ before: undefined, limit: messagePageSize },
{ before: messages.at(-messagePageSize)!.id, limit: 20 },
{ before: messages.at(-messagePageSize)!.id, limit: messagePageSize },
])
expect(roots).toEqual([])
@@ -9,11 +9,11 @@ test.use({ viewport: { width: 1440, height: 900 }, serviceWorkers: "block" })
for (const window of ["assistant-only", "mixed"] as const) {
test(`renders the ${window} latest page before parent hydration and preserves it afterward`, async ({ page }) => {
const session = { ...fixture.sessions[0]!, id: `ses_hydration_${window}` }
// Compact's initial 40 and the next 20 begin with an assistant; page three supplies its parent.
const messages = Array.from({ length: 61 }, (_, index): SessionMessageInfo => {
// Both 20-message pages begin with an assistant; only page three supplies its parent.
const messages = Array.from({ length: 41 }, (_, index): SessionMessageInfo => {
const id = `msg_hydration_${index}`
const time = { created: 1700000000000 + index * 1_000 }
if (index === 0 || (window === "mixed" && index === 59))
if (index === 0 || (window === "mixed" && index === 39))
return { id, type: "user", time, text: `Prompt ${index}` }
return {
id,
@@ -21,7 +21,7 @@ for (const window of ["assistant-only", "mixed"] as const) {
time: { ...time, completed: time.created + 500 },
model: { id: "claude-opus-4-6", providerID: "opencode" },
agent: "build",
content: [{ type: "text", text: index === 60 ? "## Hydrated tail\n\n**Ready.**" : `Answer ${index}` }],
content: [{ type: "text", text: index === 40 ? "## Hydrated tail\n\n**Ready.**" : `Answer ${index}` }],
}
})
const gates = [21, 1].map((index) => ({
@@ -43,18 +43,18 @@ for (const window of ["assistant-only", "mixed"] as const) {
await gate.release.promise
},
pageMessages: (_, limit, before) => {
expect(limit).toBe(before ? 20 : 40)
expect(limit).toBe(20)
const end = before ? messages.findIndex((message) => message.id === before) : messages.length
const start = Math.max(0, end - limit)
return { items: messages.slice(start, end), cursor: start > 0 ? messages[start]!.id : undefined }
},
})
const tail = page.locator('[data-timeline-part-id="msg_hydration_60:text:0"]')
const tail = page.locator('[data-timeline-part-id="msg_hydration_40:text:0"]')
const markdown = tail.locator('[data-component="markdown"]')
const content = page.locator("[data-timeline-virtual-content]", { has: tail })
const viewport = page.locator(".scroll-view__viewport", { has: tail })
const orphan = page.locator('[data-timeline-row="AssistantPart"]', {
has: page.locator('[data-timeline-part-id="msg_hydration_58:text:0"]'),
has: page.locator('[data-timeline-part-id="msg_hydration_38:text:0"]'),
})
const expectReadyTail = async () => {
await expect(content).toHaveCSS("visibility", "visible")
@@ -75,7 +75,7 @@ for (const window of ["assistant-only", "mixed"] as const) {
await expect(orphan).toHaveAttribute("data-message-id", "msg_hydration_21")
if (window === "mixed")
await expect(
page.locator('[data-timeline-row="UserMessage"][data-message-id="msg_hydration_59"]'),
page.locator('[data-timeline-row="UserMessage"][data-message-id="msg_hydration_39"]'),
).toBeInViewport()
const original = await markdown.elementHandle()
@@ -7,6 +7,7 @@ import {
compactionFailed,
compactionStarted,
directory,
event,
session,
sessionID,
setupTimeline,
@@ -86,13 +87,12 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
expect(ownerWarnings).toEqual([])
})
test("renders compaction progress, summary, and outcome in order", async ({ page }) => {
test("renders a compaction summary while it streams and after completion", async ({ page }) => {
const timeline = await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, notices: { placement: "separate" } },
},
sessionMessages: [user, assistant(true)],
sessionStatus: { [sessionID]: { type: "busy" } },
})
await timeline.send(
@@ -104,15 +104,7 @@ test("renders compaction progress, summary, and outcome in order", async ({ page
)
const compaction = page.locator('[data-component="session-compaction-message"]')
await expect(compaction.getByText("Session compaction started", { exact: true })).toBeVisible()
await expect(compaction.getByRole("status").getByLabel("Compacting", { exact: true })).toBeVisible()
await expect(compaction.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
await expect(compaction.getByText("Session compacted", { exact: true })).toHaveCount(0)
await expect(page.getByRole("button", { name: "Stop", exact: true })).toBeVisible()
await expect(page.locator('[data-component="session-working"]')).toHaveCount(0)
await page.setViewportSize({ width: 480, height: 900 })
await expect(compaction.getByText("Session compaction started", { exact: true })).toBeInViewport()
await expect(compaction.getByText("Session compacted", { exact: true })).toBeVisible()
await timeline.send(
compactionDelta({
@@ -122,8 +114,6 @@ test("renders compaction progress, summary, and outcome in order", async ({ page
)
await expect(compaction.getByRole("heading", { name: "Checkpoint" })).toBeVisible()
await expect(compaction).toContainText("Streamed implementation details.")
await expect(compaction.getByRole("status").getByLabel("Compacting", { exact: true })).toBeVisible()
await expect(compaction.getByText("Session compacted", { exact: true })).toHaveCount(0)
await timeline.send(
compactionEnded({
@@ -135,18 +125,6 @@ test("renders compaction progress, summary, and outcome in order", async ({ page
)
await expect(compaction).toContainText("Final implementation details.")
await expect(compaction).not.toContainText("Streamed implementation details.")
await expect(compaction.getByText("Session compaction started", { exact: true })).toBeVisible()
await expect(compaction.getByText("Session compacted", { exact: true })).toBeVisible()
await expect
.poll(async () => {
const summary = await compaction.locator('[data-component="text-part"]').boundingBox()
const completed = await compaction.getByText("Session compacted", { exact: true }).boundingBox()
return !!summary && !!completed && completed.y >= summary.y + summary.height
})
.toBe(true)
await expect(compaction.getByRole("status")).toHaveCount(0)
await expect(page.getByRole("button", { name: "Stop", exact: true })).toBeVisible()
await expect(page.locator('[data-component="session-working"]')).toBeVisible()
})
test("updates running compactions to failed and cancelled boundaries", async ({ page }) => {
@@ -168,10 +146,7 @@ test("updates running compactions to failed and cancelled boundaries", async ({
const compactions = page.locator('[data-component="session-compaction-message"]')
const failed = compactions.filter({ hasText: "The provider rejected the summary." })
await expect(failed.getByText("Session compaction started", { exact: true })).toBeVisible()
await expect(failed.getByText("Session compaction failed", { exact: true })).toBeVisible()
await expect(failed.getByText("Session compacted", { exact: true })).toHaveCount(0)
await expect(failed.getByRole("status")).toHaveCount(0)
await expect(failed.getByText("Session compacted", { exact: true })).toBeVisible()
await expect(failed.getByText("ProviderError: The provider rejected the summary.", { exact: true })).toBeVisible()
await expect(failed).not.toContainText("Partial summary that should be discarded.")
@@ -189,48 +164,11 @@ test("updates running compactions to failed and cancelled boundaries", async ({
await expect(compactions).toHaveCount(2)
const cancelled = compactions.filter({ hasNotText: "The provider rejected the summary." })
await expect(cancelled.getByText("Session compaction started", { exact: true })).toBeVisible()
await expect(cancelled.getByText("Session compaction cancelled", { exact: true })).toBeVisible()
await expect(cancelled.getByText("Session compacted", { exact: true })).toHaveCount(0)
await expect(cancelled.getByRole("status")).toHaveCount(0)
await expect(cancelled.getByText("Session compacted", { exact: true })).toBeVisible()
await expect(cancelled).not.toContainText("Cancellation detail should stay hidden.")
await expect(cancelled).not.toContainText("Summary before cancellation.")
})
test("shows an interrupted outcome when stopping automatic compaction", async ({ page }) => {
const timeline = await setupTimeline(page, {
sessionMessages: [user, assistant(true)],
sessionStatus: { [sessionID]: { type: "busy" } },
})
await timeline.send(compactionStarted({ sessionID, reason: "auto", recent: "" }))
await timeline.send(compactionDelta({ sessionID, text: "Partial automatic summary." }))
const compaction = page.locator('[data-component="session-compaction-message"]')
await expect(compaction.getByRole("status").getByLabel("Compacting", { exact: true })).toBeVisible()
await expect(compaction).toContainText("Partial automatic summary.")
const request = page.waitForRequest(
(request) =>
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/interrupt`,
)
await page.getByRole("button", { name: "Stop", exact: true }).click()
await request
await timeline.send(
compactionFailed({
sessionID,
reason: "auto",
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
}),
)
await expect(compaction.getByText("Session compaction started", { exact: true })).toBeVisible()
await expect(compaction.getByText("Session compaction interrupted", { exact: true })).toBeVisible()
await expect(compaction.getByText("Session compaction failed", { exact: true })).toHaveCount(0)
await expect(compaction.getByText("Session compacted", { exact: true })).toHaveCount(0)
await expect(compaction.getByRole("status")).toHaveCount(0)
await expect(compaction).not.toContainText("Partial automatic summary.")
await expect(compaction).not.toContainText("Compaction was interrupted")
})
test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
await setupTimeline(page, {
settings: {
@@ -1,68 +0,0 @@
import { expect, test } from "@playwright/test"
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode/client/promise"
import { mockOpenCodeServer } from "../utils/mock-server"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { installStressSessionTabs, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
test.use({ viewport: { width: 1440, height: 900 }, serviceWorkers: "block" })
test("keeps five loaded workspace tabs visible and reactive through repeated switches", async ({ page }, info) => {
const sessions = Array.from({ length: 5 }, (_, index) => ({
...fixture.sessions[0]!,
id: `ses_workspace_cycle_${index}`,
directory: `${fixture.directory}/worktree-${index}`,
title: `Workspace session ${index}`,
}))
const events: OpenCodeEvent[] = []
await mockOpenCodeServer(page, {
...fixture,
sessions,
pageMessages: (id) => ({
items: [
{ id: `msg_user_${id}`, type: "user", text: `Prompt for ${id}`, time: { created: 1 } },
{
id: `msg_assistant_${id}`,
type: "assistant",
agent: "build",
model: { id: "claude-opus-4-6", providerID: "opencode" },
time: { created: 2, completed: 3 },
content: [{ type: "text", text: `Answer for ${id}` }],
},
] satisfies SessionMessageInfo[],
}),
events: () => events.splice(0),
})
await page.route("**/api/location?*", (route) =>
route.fulfill({
json: {
directory: new URL(route.request().url()).searchParams.get("location[directory]"),
project: { id: fixture.project.id, directory: fixture.directory, canonical: fixture.directory },
},
}),
)
await installStressSessionTabs(page, { sessionIDs: sessions.map((session) => session.id) })
await page.goto(stressSessionHref(sessions[0]!.id))
await expect(page.getByText(`Answer for ${sessions[0]!.id}`, { exact: true })).toBeVisible()
for (const session of [...sessions.slice(1), ...sessions, ...sessions.toReversed()]) {
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(session.id)}"]`).click()
await expect(page.locator(`[data-timeline-part-id="msg_assistant_${session.id}:text:0"]`)).toBeVisible()
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
}
const active = sessions[0]!
events.push({
id: "evt_workspace_cycle_update",
created: 4,
type: "session.text.ended",
location: { directory: active.directory },
durable: { aggregateID: active.id, seq: 0, version: 1 },
data: {
sessionID: active.id,
assistantMessageID: `msg_assistant_${active.id}`,
ordinal: 0,
text: "Still receiving updates",
},
})
await expect(page.getByText("Still receiving updates", { exact: true })).toBeVisible()
await page.screenshot({ path: info.outputPath("workspace-tabs.png") })
})
@@ -1,86 +0,0 @@
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
for (const colorScheme of ["light", "dark"] as const) {
test.describe(colorScheme, () => {
test.use({ colorScheme, contextOptions: { reducedMotion: "reduce" } })
test("project card edges stay inside the settings scrollport", async ({ page }, info) => {
const projects = ["rebase", "dinocms", "opencode", "Playground"].map((name, index) => ({
id: `project-${index}`,
name,
canonical: `/projects/${name}`,
vcs: "git",
time: { created: 1, updated: 1 },
sandboxes: [],
}))
await mockOpenCodeServer(page, {
directory: "/projects/rebase",
project: projects[0],
sessions: [],
pageMessages: () => ({ items: [] }),
provider: { all: [], connected: [], default: {} },
})
await page.route("**/api/project", (route) =>
route.fulfill({ json: projects, headers: { "access-control-allow-origin": "*" } }),
)
await page.addInitScript((projects) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: projects.map((project) => ({ worktree: project.canonical, expanded: true })) },
}),
)
}, projects)
await page.goto("/")
await expect(page.getByRole("button", { name: "Settings", exact: true })).toBeEnabled()
await page.getByRole("button", { name: "Settings", exact: true }).click()
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
const panel = settings.getByRole("tabpanel")
await expect(panel.getByText("rebase", { exact: true })).toBeVisible()
await expect(panel.getByText("Playground", { exact: true })).toBeVisible()
await page.evaluate(() => document.fonts.ready)
for (const width of [1280, 1050, 960, 720, 600]) {
await page.setViewportSize({ width, height: 720 })
await page.mouse.move(0, 0)
await page.screenshot({ path: info.outputPath(`projects-${width}.png`), animations: "disabled" })
// Raised cards paint a half-pixel border outside their box. The scrollport
// must leave room for that border and the soft shadow on both sides.
await expect
.poll(() =>
panel.getByText("rebase", { exact: true }).evaluate((label) => {
const row = label.parentElement!.parentElement!
const bounds = row.getBoundingClientRect()
const clips = []
for (let parent = row.parentElement; parent; parent = parent.parentElement) {
if (getComputedStyle(parent).overflowX === "visible") continue
const clip = parent.getBoundingClientRect()
clips.push(bounds.left - clip.left, clip.right - bounds.right)
}
return Math.min(...clips)
}),
)
.toBeGreaterThanOrEqual(4)
await expect(panel).toHaveJSProperty("scrollWidth", await panel.evaluate((el) => el.clientWidth))
}
await page.setViewportSize({ width: 1280, height: 720 })
await panel.getByText("rebase", { exact: true }).hover()
await panel.getByText("rebase", { exact: true }).click()
const dialog = page.getByRole("dialog")
await expect(dialog.getByRole("textbox")).toHaveValue("rebase")
await expect(dialog.getByRole("textbox")).toBeFocused()
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
await expect(dialog).toBeHidden()
await expect(panel.getByText("rebase", { exact: true })).toBeVisible()
await page.setViewportSize({ width: 1280, height: 260 })
await panel.getByText("rebase", { exact: true }).hover()
await page.mouse.wheel(0, 400)
await expect(panel.getByText("Playground", { exact: true })).toBeInViewport({ ratio: 1 })
await expect(panel.getByRole("heading", { name: "Projects", exact: true })).toBeInViewport({ ratio: 1 })
})
})
}
-6
View File
@@ -80,7 +80,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const id = state.connections
let ended = false
let own: ReadableStreamDefaultController<Uint8Array> | undefined
let keepalive: ReturnType<typeof setInterval> | undefined
const stream = new ReadableStream<Uint8Array>({
start(controller) {
own = controller
@@ -90,15 +89,11 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
)
state.buffer.splice(0).forEach((item) => controller.enqueue(encoder.encode(item)))
// Match the real server's idle stream so long scenarios do not
// trigger the client's 45-second stall watchdog and reload history.
keepalive = setInterval(() => controller.enqueue(encoder.encode(": keepalive\n\n")), 15_000)
request.signal.addEventListener(
"abort",
() => {
if (ended) return
ended = true
clearInterval(keepalive)
if (state.controller === controller) state.controller = undefined
controller.error(request.signal.reason ?? new DOMException("The operation was aborted", "AbortError"))
},
@@ -108,7 +103,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
cancel() {
if (ended) return
ended = true
clearInterval(keepalive)
if (state.controller === own) state.controller = undefined
},
})
+5 -10
View File
@@ -122,15 +122,9 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
if (command) {
clearSubmission(input, submission)
// Commands always steer: the server applies a command's configured
// agent and model immediately at admission, so queueing one would
// reconfigure the turn it is supposed to wait behind.
void sendCommand(
session,
{ ...value, delivery: "steer" },
command,
input.adapter.controls().model.selection.trackSessionCommit,
).catch((error) => failSubmission(input, session, "command", error, restore, value.id))
void sendCommand(session, value, command, input.adapter.controls().model.selection.trackSessionCommit).catch(
(error) => failSubmission(input, session, "command", error, restore, value.id),
)
return
}
} finally {
@@ -322,7 +316,8 @@ async function sendCommand(
track?: ModelSelection["trackSessionCommit"],
) {
const request = await buildSubmissionRequest(session, value)
await applySelection(session, value.selection, track)
// Like queued prompts, queued commands must not apply the composer's selection to active work.
if (value.delivery === "steer") await applySelection(session, value.selection, track)
await session.api.command({
sessionID: session.id,
command: command.command,
@@ -242,7 +242,14 @@ export function PromptWorkspaceSelector(props: {
class="ms-1 min-w-0 max-w-[220px]"
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<Menu placement="bottom" gutter={4} onOpenChange={onOpenChange}>
<Menu
placement="bottom"
gutter={4}
onOpenChange={(open) => {
onOpenChange(open)
if (open) requestAnimationFrame(() => branchSearchInput?.focus())
}}
>
<Menu.Trigger class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-background-bg-layer-03 hover:text-v2-text-text-muted focus-visible:bg-v2-background-bg-layer-03 focus-visible:text-v2-text-text-muted focus-visible:outline-none data-[expanded]:bg-v2-background-bg-layer-03 data-[expanded]:text-v2-text-text-muted">
<Icon name="branch-out" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span ref={branchTruncation.observe} class="min-w-0 truncate">
@@ -251,14 +258,7 @@ export function PromptWorkspaceSelector(props: {
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</Menu.Trigger>
<Menu.Portal>
<Menu.Content
class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none"
onOpenAutoFocus={(event) => {
event.preventDefault()
// Kobalte defers its list autofocus until after the focus scope opens.
setTimeout(() => requestAnimationFrame(() => branchSearchInput?.focus({ preventScroll: true })))
}}
>
<Menu.Content class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none">
<div class="flex h-7 shrink-0 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
<Icon name="magnifying-glass" size="small" class="shrink-0" />
<input
@@ -34,27 +34,6 @@ function setup(input?: {
}
describe("createRequestQueue", () => {
test("starts a free slot before the caller continues its synchronous work", async () => {
const input = setup()
const response = input.queue.fetch("http://server/api/session")
expect(input.pending.map((item) => new URL(item.url).pathname)).toEqual(["/api/session"])
expect(input.queue.inflight()).toBe(1)
input.pending[0]!.resolve()
await response
expect(input.queue.inflight()).toBe(0)
})
test("releases a free slot without sending an already-aborted request", async () => {
const input = setup()
const controller = new AbortController()
controller.abort()
await expect(input.queue.fetch("http://server/api/session", { signal: controller.signal })).rejects.toBeInstanceOf(
DOMException,
)
expect(input.pending).toHaveLength(0)
expect(input.queue.inflight()).toBe(0)
})
test("caps concurrent requests and starts queued ones as slots free up", async () => {
const input = setup()
const responses = ["/api/a", "/api/b", "/api/c"].map((path) => input.queue.fetch(`http://server${path}`))
@@ -130,10 +109,12 @@ describe("createRequestQueue", () => {
const input = setup({ limit: 1, headersTimeoutMs: 10 })
const dead = input.queue.fetch("http://server/api/dead")
const next = input.queue.fetch("http://server/api/next")
await input.settle()
expect(input.queue.queued()).toBe(1)
const error = await dead.catch((cause: unknown) => cause)
expect(error).toBeInstanceOf(DOMException)
expect((error as DOMException).name).toBe("TimeoutError")
await input.settle()
expect(input.pending.map((item) => new URL(item.url).pathname)).toEqual(["/api/dead", "/api/next"])
input.pending[1]!.resolve()
await expect(next).resolves.toBeInstanceOf(Response)
@@ -84,23 +84,17 @@ export function createRequestQueue(input: {
if (index === -1) return
waiting.splice(index, 1)[0]?.start()
}
const acquire = (entry: Entry) => {
// A free slot must start fetch before the caller's synchronous UI work.
// Awaiting an already-resolved promise postpones that dispatch until after it.
if (canStart(entry)) {
inflight.add(entry)
return
}
return new Promise<void>((resolve) => {
const acquire = (entry: Entry) =>
new Promise<void>((resolve) => {
const start = () => {
entry.at = now()
inflight.add(entry)
resolve()
}
if (canStart(entry)) return start()
waiting.push({ entry, start })
watcher ??= setTimeout(watch, stallMs)
})
}
const fetch: typeof globalThis.fetch = Object.assign(
async (resource: RequestInfo | URL, init?: RequestInit) => {
@@ -109,8 +103,7 @@ export function createRequestQueue(input: {
// The event stream is long-lived; never count it against the request budget.
if (pathname === "/api/event") return base(request)
const entry = { method: request.method, url: request.url, at: now(), slow: isSlowRequest(pathname) }
const queued = acquire(entry)
if (queued) await queued
await acquire(entry)
if (request.signal.aborted) {
release(entry)
throw request.signal.reason ?? new DOMException("The operation was aborted.", "AbortError")
@@ -16,8 +16,6 @@ import { ModelState } from "./persistence"
import { useLanguage } from "@/runtime/i18n/language"
import { showToast } from "@/shell/notifications/toast"
import { formatServerError } from "./errors"
import { useSettings } from "@/settings/model"
import { timelinePreset } from "@opencode/session-ui/timeline/detail"
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
name: "Global",
@@ -133,12 +131,10 @@ function createServerController(
projects: ReturnType<typeof createServerProjects>,
) {
const language = useLanguage()
const settings = useSettings()
const connKey = ServerConnection.key(conn)
const sdk = createServerSdkContext(conn, scope)
const source = createData({
api: () => sdk.api,
initialMessageLimit: () => (timelinePreset(settings.general.timelineDetail())?.id === "compact" ? 40 : 20),
event: {
on: sdk.event.on,
listen: (handler) => sdk.event.listen((event) => handler({ name: event.type, details: event })),
+1 -7
View File
@@ -108,13 +108,6 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
}
createEffect(() => !store.editing && setStore("address", address()))
createEffect(
on(registration, (current) => {
// Session routes can change before this pane unmounts. Hide the registration
// that owned the native view, rather than reading the destination's handle.
onCleanup(() => current?.setLayout())
}),
)
createEffect(
on(
[
@@ -147,6 +140,7 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
createEventListener(document, "visibilitychange", () => setStore("visible", document.visibilityState === "visible"))
onCleanup(() => {
if (frame !== undefined) cancelAnimationFrame(frame)
registration()?.setLayout()
})
return (
@@ -6,7 +6,7 @@ import { StatusPopover } from "@/shell/status/status-popover"
import { TitlebarRight } from "@/shell/titlebar/right-slot"
import { Tooltip } from "@opencode/ui/tooltip"
export function SessionHeader(props: { reserveReviewToggle: boolean }) {
export function SessionHeader() {
const language = useLanguage()
const settings = useSettings()
@@ -21,7 +21,8 @@ export function SessionHeader(props: { reserveReviewToggle: boolean }) {
</Tooltip>
</Show>
</TitlebarRight>
<Show when={isDesktop() && props.reserveReviewToggle}>
{/* Keep the fixed toggle's slot mounted throughout panel motion. */}
<Show when={isDesktop()}>
<div class="size-7 shrink-0" aria-hidden />
</Show>
</>
+30 -41
View File
@@ -34,7 +34,6 @@ import { SessionIdentityHeader } from "./session-identity-header"
import { SessionReviewToggle } from "./header/session-header-actions"
import { createAnimatedPresence } from "@/runtime/animated-presence"
import { createSessionBrowser } from "./browser/model"
import { createTimelineCache } from "./timeline/cache"
const SessionMobileFiles = lazy(async () => {
const { SessionMobileFiles } = await import("./files/session-mobile-files")
@@ -220,45 +219,6 @@ export function SessionScreen(props: { session: SessionModel }) {
</Show>
)
const timelineView = createTimelineCache(
session,
(source, active) => (
<MessageTimeline
active={active()}
hideHeader={!isDesktop()}
session={source}
background={composer.requests.background}
actions={composer.actions.timeline}
scroll={timeline.scroll}
onResumeScroll={timeline.actions.resume}
setScrollRef={timeline.view.setScrollRef}
onScheduleScrollState={timeline.view.scheduleScrollState}
onPin={timeline.view.pin}
onUnpin={timeline.view.unpin}
onUserScroll={timeline.view.markUserScroll}
onHistoryScroll={timeline.view.onHistoryScroll}
onSelectionInteraction={timeline.view.selectionInteraction}
pinned={timeline.view.pinned()}
centered={screen.centered()}
reserveReviewToggle={!sideVisible()}
setContentRef={timeline.view.setContentRef}
diffs={review.details.diffs}
onReview={review.open}
workspaceMoveEligible={composer.workspaceMoveEligible()}
onSummaryOpenChange={review.details.setOpen}
anchor={timeline.view.anchor}
setRevealMessage={timeline.view.setRevealMessage}
setScrollToEnd={timeline.view.setScrollToEnd}
search={
<Show when={active()}>
<TimelineSearchBar controller={timelineSearch} />
</Show>
}
/>
),
() => conversationVisible() && messagesReady(),
)
const sessionPanelContent = () => (
<>
<ComposerDropzone
@@ -298,7 +258,36 @@ export function SessionScreen(props: { session: SessionModel }) {
<Show when={isDesktop() && !messagesReady()}>
<SessionIdentityHeader sessionID={session.identity.params.id ?? ""} session={session.data.info()} />
</Show>
<Show when={messagesReady() && session.identity.params.id}>{timelineView()}</Show>
<Show when={messagesReady() ? session.identity.params.id : undefined} keyed>
{(_id) => (
<MessageTimeline
hideHeader={!isDesktop()}
session={session}
background={composer.requests.background}
actions={composer.actions.timeline}
scroll={timeline.scroll}
onResumeScroll={timeline.actions.resume}
setScrollRef={timeline.view.setScrollRef}
onScheduleScrollState={timeline.view.scheduleScrollState}
onPin={timeline.view.pin}
onUnpin={timeline.view.unpin}
onUserScroll={timeline.view.markUserScroll}
onHistoryScroll={timeline.view.onHistoryScroll}
onSelectionInteraction={timeline.view.selectionInteraction}
pinned={timeline.view.pinned()}
centered={screen.centered()}
setContentRef={timeline.view.setContentRef}
diffs={review.details.diffs}
onReview={review.open}
workspaceMoveEligible={composer.workspaceMoveEligible()}
onSummaryOpenChange={review.details.setOpen}
anchor={timeline.view.anchor}
setRevealMessage={timeline.view.setRevealMessage}
setScrollToEnd={timeline.view.setScrollToEnd}
search={<TimelineSearchBar controller={timelineSearch} />}
/>
)}
</Show>
</Match>
</Switch>
</div>
@@ -1,69 +0,0 @@
import {
createComputed,
createMemo,
createRoot,
getOwner,
on,
onCleanup,
untrack,
type Accessor,
type JSX,
} from "solid-js"
import { createScopedCache } from "@/runtime/server/scoped-cache"
import type { TimelineSessionSource } from "./controller"
export function createTimelineCache(
session: TimelineSessionSource & { identity: { workspaceKey: Accessor<string> } },
render: (source: TimelineSessionSource, active: Accessor<boolean>) => JSX.Element,
visible: Accessor<boolean>,
) {
const owner = getOwner()
let workspace = untrack(session.identity.workspaceKey)
const cache = createScopedCache(
(key) =>
createRoot((dispose) => {
const id = untrack(session.identity.sessionID)
const sessionKey = untrack(session.identity.sessionKey)
const active = createMemo(() => visible() && session.identity.sessionKey() === key)
// A detached view retains its own inputs until it is selected again.
const select = <T>(read: Accessor<T>) =>
createMemo<T>((previous) => (active() ? read() : previous), untrack(read))
return {
value: render(
{
identity: {
params: session.identity.params,
sessionID: () => id,
sessionKey: () => sessionKey,
},
data: {
info: select(session.data.info),
parent: select(session.data.parent),
parentID: select(session.data.parentID),
status: select(session.data.status),
},
history: { messages: select(session.history.messages) },
},
active,
),
dispose,
}
}, owner),
{ maxEntries: 16, dispose: (entry) => entry.dispose() },
)
onCleanup(cache.clear)
const syncWorkspace = (key: string) => {
if (workspace === key) return
workspace = key
cache.clear()
}
// Providers follow the selected Location even while its history is loading.
// Dispose detached views before their effects can read the new Location.
createComputed(on(session.identity.workspaceKey, syncWorkspace, { defer: true }))
return () => {
// A tab's render can run before the workspace watcher in the same batch.
// Clear the old workspace here, and let that later watcher keep this view.
syncWorkspace(session.identity.workspaceKey())
return cache.get(session.identity.sessionKey()).value
}
}
@@ -349,7 +349,6 @@ export function SessionSummaryPanel(props: {
type MessageTimelineProps = {
hideHeader?: boolean
active?: boolean
session: TimelineSessionSource
background: SessionBackground
actions?: SessionUserActions
@@ -364,7 +363,6 @@ type MessageTimelineProps = {
onSelectionInteraction: (event: MouseEvent) => void
pinned: boolean
centered: boolean
reserveReviewToggle: boolean
setContentRef: (el: HTMLDivElement) => void
diffs: Accessor<{ additions: number; deletions: number }[] | undefined>
onReview: () => void
@@ -460,7 +458,6 @@ function MessageTimelineView(
const pinned = createMemo(() => props.pinned)
const messageByID = projection.messageByID
const virtualized = createTimelineVirtualizer({
active: () => props.active !== false,
sessionKey: () => `${server.key}/${props.data.sessionID()}`,
presentationKey: () => JSON.stringify(props.data.timelineDetail()),
projection,
@@ -554,12 +551,6 @@ function MessageTimelineView(
if (await props.action.rename(title.draft)) setTitle("editing", false)
}
createEffect(() => {
if (props.active !== false) return
setSummary(false)
setTitle({ draft: "", editing: false, menuOpen: false, pendingRename: false })
})
const rowRenderer = createSessionTimelineRowRenderer({
sessionID: () => sessionID()!,
status: sessionStatus,
@@ -854,7 +845,7 @@ function MessageTimelineView(
</Popover>
)}
</Show>
<SessionHeader reserveReviewToggle={props.reserveReviewToggle} />
<SessionHeader />
</div>
)}
</Show>
+4 -5
View File
@@ -11,17 +11,17 @@ export {
selectVisibleSessionUserMessages as selectVisibleUserMessages,
} from "../session-domain"
export function createTimelineModel(input: { session: Pick<SessionModel, "identity" | "history" | "ownership"> }) {
export function createTimelineModel(input: { session: Pick<SessionModel, "identity" | "history"> }) {
const data = useData()
const [resource] = createResource(
() => input.session.identity.sessionID(),
async (id) => {
if (!id) return
const owner = input.session.ownership.capture()
const key = input.session.identity.sessionKey()
await Promise.all([data.session.message.sync(id), data.session.pending.sync(id)])
await enrichLeadingTurn({
current: owner.current,
current: () => input.session.identity.sessionKey() === key,
messages: () => data.session.message.list(id),
more: () => data.session.message.more(id),
loading: () => data.session.message.loading(id),
@@ -29,13 +29,12 @@ export function createTimelineModel(input: { session: Pick<SessionModel, "identi
pause: () => new Promise((resolve) => setTimeout(resolve, leadingTurnPageDelay)),
maxPages: leadingTurnPageLimit,
}).catch(() => undefined)
return id
},
)
const ready = createMemo(() => {
const id = input.session.identity.sessionID()
// Enrich the partial leading group without withholding the already loaded tail.
return !id || data.session.message.list(id).length > 0 || (!resource.loading && resource.latest === id)
return !id || data.session.message.list(id).length > 0 || !resource.loading
})
const more = () => {
const id = input.session.identity.sessionID()
@@ -16,35 +16,6 @@ test("matches only the scroll element or an ancestor containing it", () => {
expect(mutationNodesContainElement([child, sibling], viewport)).toBe(false)
})
test("restores a view observed before its first attachment", async () => {
const targetWindow = new Window()
const mutations = controlledMutations(targetWindow)
const viewport = targetWindow.document.createElement("div")
const instance = {
scrollElement: viewport,
targetWindow,
scrollOffset: 240,
options: { horizontal: false, isRtl: false, isScrollingResetDelay: 0, useScrollendEvent: false },
} as unknown as Virtualizer<HTMLDivElement, HTMLDivElement>
const connections: boolean[] = []
const cleanup = observeElementOffsetReconnectAware(
instance,
(offset) => {
instance.scrollOffset = offset
},
() => connections.push(viewport.isConnected),
)
try {
mutations.append(targetWindow.document.body, viewport)
await frames(2, targetWindow)
expect(connections).toEqual([true])
expect(instance.scrollOffset).toBe(0)
} finally {
cleanup()
await targetWindow.happyDOM.close()
}
})
test("reports a divergent native offset once and ignores equal offsets and unrelated mutations", async () => {
const targetWindow = new Window()
const mutations = controlledMutations(targetWindow)
@@ -20,8 +20,7 @@ export function observeElementOffsetReconnectAware<TScrollElement extends Elemen
cleanupOffset?.()
}
// Cached views can be constructed before their first attachment to the page.
let removed = !element.isConnected
let removed = false
let frame: number | undefined
const clearCheck = () => {
if (frame === undefined) return
@@ -2,7 +2,6 @@ import {
createVirtualizer,
defaultRangeExtractor,
elementScroll,
observeElementRect,
type Range,
type VirtualItem,
} from "@tanstack/solid-virtual"
@@ -50,7 +49,6 @@ type Projection = Pick<
>
type Input = {
active?: Accessor<boolean>
sessionKey: Accessor<string>
presentationKey?: Accessor<string>
projection: Projection
@@ -85,7 +83,6 @@ type ViewProps = {
export function createTimelineVirtualizer(input: Input) {
const language = useLanguage()
const active = () => input.active?.() !== false
const isDesktop = createMediaQuery("(min-width: 768px)")
const topOffset = () => (input.showHeader() ? 64 : isDesktop() ? 0 : 16)
const ownerSessionKey = input.sessionKey()
@@ -137,7 +134,7 @@ export function createTimelineVirtualizer(input: Input) {
!(
row._tag === "AssistantPart" &&
row.group.type === "context" &&
row.group.refs.length <= 64 &&
row.group.refs.length <= 16 &&
!toolOpen[`context:${row.group.key}`]
) && !input.canRenderImmediately?.(row, toolOpen),
)
@@ -159,7 +156,6 @@ export function createTimelineVirtualizer(input: Input) {
let virtualContent: HTMLDivElement | undefined
let scrollTop = 0
let reportOffset: ((offset: number, scrolling: boolean) => void) | undefined
let reportRect: ((rect: { width: number; height: number }) => void) | undefined
let batchingColdSizes = false
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
@@ -167,22 +163,13 @@ export function createTimelineVirtualizer(input: Input) {
return rows().length
},
getScrollElement: () => listRoot() ?? null,
observeElementRect: (instance, callback) => {
reportRect = callback
return observeElementRect(instance, (rect) => {
if (active()) callback(rect)
})
},
// Route navigation detaches and reattaches the scroll element, which drops its offset.
observeElementOffset: (instance, callback) => {
reportOffset = (offset, scrolling) => {
if (!active()) return
callback(offset, scrolling)
settleColdBottom()
}
return observeElementOffsetReconnectAware(instance, reportOffset, () => {
if (!active()) return
virtualContent?.querySelectorAll<HTMLDivElement>("[data-index]").forEach(virtualizer.measureElement)
if (input.pinned()) virtualizer.scrollToEnd()
settleColdBottom()
})
@@ -194,11 +181,6 @@ export function createTimelineVirtualizer(input: Input) {
// its snapshot on attachment, but later explicit measurements must read layout
// so deferred/rewrapped content cannot keep stale, clipped heights (TanStack/virtual#1183).
measureElement: (element, entry, instance) => {
if (!active() || !element.isConnected)
return (
instance.itemSizeCache.get(instance.options.getItemKey(instance.indexFromElement(element))) ??
fallbackItemSize
)
const initial = !measuredElements.has(element)
measuredElements.add(element)
const box = entry?.borderBoxSize[0]
@@ -210,7 +192,6 @@ export function createTimelineVirtualizer(input: Input) {
return element.offsetHeight
},
scrollToFn: (offset, options, instance) => {
if (!active()) return
if (batchingColdSizes && input.pinned()) return
if (virtualContent) virtualContent.style.height = `${instance.getTotalSize()}px`
elementScroll(offset, options, instance)
@@ -240,7 +221,6 @@ export function createTimelineVirtualizer(input: Input) {
// Read the whole measurement delivery before committing reactive row sizes.
// Otherwise each row can render and force layout before the next is measured.
virtualizer.resizeItem = (index, size) => {
if (!active()) return
const row = rows()[index]
if (!row) return
const key = TimelineRow.key(row)
@@ -256,7 +236,6 @@ export function createTimelineVirtualizer(input: Input) {
if (!pendingSizes.size) return
const sizes = [...pendingSizes]
pendingSizes.clear()
if (!active()) return
// The hidden pinned mount needs one bottom write after the whole batch,
// not a layout-forcing scroll adjustment for every measured row.
batchingColdSizes = coldPending && input.pinned()
@@ -292,21 +271,7 @@ export function createTimelineVirtualizer(input: Input) {
const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => String(item.key)))
createEffect(() => {
if (!active()) return
const root = listRoot()
if (root) input.setScrollRef(root)
if (virtualContent) input.setContentRef(virtualContent)
queueMicrotask(() => {
if (!active() || !root?.isConnected) return
// A detached view can miss its nonzero ResizeObserver delivery. Publish
// its real viewport before restoring the offset and admitting rows.
reportRect?.({ width: root.offsetWidth, height: root.offsetHeight })
if (input.pinned()) virtualizer.scrollToEnd()
reportOffset?.(root.scrollTop, false)
settleColdBottom()
})
input.setRevealMessage?.((id, partID) => {
if (!active()) return
const partIndex = partID
? rows().findIndex(
(row) => row._tag === "AssistantPart" && row.group.type === "part" && row.group.ref.partID === partID,
@@ -317,7 +282,6 @@ export function createTimelineVirtualizer(input: Input) {
virtualizer.scrollToIndex(index, { align: "center" })
})
input.setScrollToEnd?.(() => {
if (!active() || !listRoot()?.isConnected) return
input.onPin()
virtualizer.scrollToEnd()
})
@@ -328,7 +292,6 @@ export function createTimelineVirtualizer(input: Input) {
let contentObserver: MutationObserver | undefined
let viewportObserver: ResizeObserver | undefined
const pinColdBottom = () => {
if (!active()) return
const root = listRoot()
if (!input.pinned() || !virtualContent || !root) return
// scrollToEnd computes its target from the DOM, not the new size cache.
@@ -346,7 +309,7 @@ export function createTimelineVirtualizer(input: Input) {
)
}
const settleColdBottom = () => {
if (!active() || !coldPending || settleQueued) return
if (!coldPending || settleQueued) return
settleQueued = true
queueMicrotask(() => {
settleQueued = false
@@ -409,7 +372,7 @@ export function createTimelineVirtualizer(input: Input) {
setListRoot(root)
scrollTop = root.scrollTop
maxScroll = root.scrollHeight - root.clientHeight
if (active()) input.setScrollRef(root)
input.setScrollRef(root)
viewportObserver?.observe(root)
settleColdBottom()
}
@@ -466,7 +429,6 @@ export function createTimelineVirtualizer(input: Input) {
// under a viewport that was already there. Merely resting near the end is not enough, otherwise
// a later scroll would overwrite an upward intent expressed a pixel short of the bottom.
const handleListScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
if (!active()) return
const root = event.currentTarget
const previousTop = scrollTop
const previousMaxScroll = maxScroll
@@ -492,8 +454,15 @@ export function createTimelineVirtualizer(input: Input) {
let contentMeasureFrame: number | undefined
onMount(() => virtualizer.measureElement(element))
// Prepending history changes data-index, not the keyed element's identity.
// Its observer reads the current index and delivers any actual size change.
createEffect(
on(
() => item().index,
() => {
virtualizer.measureElement(element)
},
{ defer: true },
),
)
onCleanup(() => {
if (contentMeasureFrame !== undefined) cancelAnimationFrame(contentMeasureFrame)
queueMicrotask(() => virtualizer.measureElement(null))
@@ -524,7 +493,7 @@ export function createTimelineVirtualizer(input: Input) {
if (contentMeasureFrame !== undefined) cancelAnimationFrame(contentMeasureFrame)
contentMeasureFrame = requestAnimationFrame(() => {
contentMeasureFrame = undefined
if (active() && element.isConnected) virtualizer.measureElement(element)
if (element.isConnected) virtualizer.measureElement(element)
})
})}
</div>
@@ -586,7 +555,7 @@ export function createTimelineVirtualizer(input: Input) {
data-timeline-virtual-content
ref={(element) => {
virtualContent = element
if (active()) input.setContentRef(element)
input.setContentRef(element)
}}
style={{
height: `${virtualizer.getTotalSize()}px`,
@@ -621,11 +590,9 @@ export function createTimelineVirtualizer(input: Input) {
coldPending = false
contentObserver?.disconnect()
viewportObserver?.disconnect()
if (active()) {
input.setScrollRef(undefined)
input.setRevealMessage?.(() => {})
input.setScrollToEnd?.(() => {})
}
input.setScrollRef(undefined)
input.setRevealMessage?.(() => {})
input.setScrollToEnd?.(() => {})
})
return {
+1 -5
View File
@@ -288,12 +288,8 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
createEffect(() => {
if (typeof document === "undefined") return
const root = document.documentElement
const mono = monoFontFamily(store.appearance?.mono)
root.style.setProperty("--font-family-mono", mono)
root.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.mono))
root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.sans))
// Inline code can first appear during history backfill. Load its selected
// face with the shell so that font discovery does not resize that mount.
void document.fonts?.load(`440 13px ${mono}`).catch(() => undefined)
})
return {
-2
View File
@@ -51,8 +51,6 @@
flex: 1;
min-width: 0;
max-width: 720px;
/* Leave room for raised card shadows inside the scrollport. */
padding-inline: 4px;
}
.settings-screen .settings-tab-header {
+10 -11
View File
@@ -1,7 +1,7 @@
import { createSimpleContext } from "@opencode/ui/context"
import { useDialog } from "@opencode/ui/context/dialog"
import { type Accessor, batch, createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { type Accessor, createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { makeEventListener } from "@solid-primitives/event-listener"
@@ -306,20 +306,19 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
createEffect(() => {
if (!catalogReady()) return
batch(() =>
registered().forEach((opt) => {
if (!opt.title) return
setCatalog(
actionId(opt.id),
reconcile({
setCatalog(
registered().reduce((acc, opt) => {
const id = actionId(opt.id)
if (opt.title)
acc[id] = {
title: opt.title,
description: opt.description,
category: opt.category,
keybind: opt.keybind,
slash: opt.slash,
}),
)
}),
}
return acc
}, {} as CommandCatalog),
)
})
@@ -1,41 +0,0 @@
import { expect, test } from "bun:test"
import { createComputed, createRoot } from "solid-js"
import { OpenCode } from "@opencode/client/promise"
import { createData } from "@opencode/client/solid"
test("publishes an initial message page, its index, and its cursor together", async () => {
const observed: { ids: string[]; more: boolean; text: string | undefined }[] = []
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async () =>
Response.json({
data: [{ id: "msg_page", type: "user", text: "History", time: { created: 1 } }],
cursor: { next: "older" },
}),
})
const setup = createRoot((dispose) => {
const data = createData({
api: () => api,
directory: "/project",
event: { on: () => () => {}, listen: () => () => {} },
})
createComputed(() => {
const message = data.session.message.get("ses_page", "msg_page")
observed.push({
ids: data.session.message.list("ses_page").map((message) => message.id),
more: data.session.message.more("ses_page"),
text: message?.type === "user" ? message.text : undefined,
})
})
return { data, dispose }
})
try {
await setup.data.session.message.sync("ses_page")
expect(observed).toEqual([
{ ids: [], more: false, text: undefined },
{ ids: ["msg_page"], more: true, text: "History" },
])
} finally {
setup.dispose()
}
})
@@ -1,196 +0,0 @@
import { expect, test } from "bun:test"
import type { SessionMessageInfo } from "@opencode/client/promise"
import { batch, createMemo, createRoot, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { ServerScope, SessionRouteKey, SessionStateKey } from "../src/runtime/server/scope"
import { createTimelineCache } from "../src/session/timeline/cache"
function setup() {
return createRoot((dispose) => {
const [state, setState] = createStore({
id: "ses_a",
directory: "/repo",
visible: true,
messages: {
ses_a: [{ id: "msg_a", type: "user", text: "First session", time: { created: 1 } }],
ses_b: [{ id: "msg_b", type: "user", text: "Second session", time: { created: 2 } }],
} as Record<string, SessionMessageInfo[]>,
})
const views = new Map<
string,
{ active: () => boolean; messages: () => SessionMessageInfo[]; element: HTMLDivElement }
>()
const disposed: string[] = []
const cache = createTimelineCache(
{
identity: {
params: {
get id() {
return state.id
},
serverKey: "local",
},
sessionID: () => state.id,
workspaceKey: () => SessionStateKey.from(ServerScope.local, SessionRouteKey.fromRoute(state.directory)),
sessionKey: () =>
SessionStateKey.from(ServerScope.local, SessionRouteKey.fromRoute(state.directory, state.id)),
},
data: {
info: createMemo(() => ({
id: state.id,
projectID: "project",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
location: { directory: state.directory },
})),
parent: () => undefined,
parentID: () => undefined,
status: () => ({ type: "idle" }),
},
history: { messages: () => state.messages[state.id] ?? [] },
},
(source, active) => {
const id = source.identity.sessionID()!
const element = document.createElement("div")
createMemo(() =>
element.setAttribute(
"data-messages",
source.history
.messages()
.map((message) => message.id)
.join(","),
),
)
views.set(id, { active, messages: source.history.messages, element })
onCleanup(() => disposed.push(id))
return element
},
() => state.visible,
)
return { state, setState, cache, views, disposed, dispose }
})
}
test("reuses a session view and refreshes its own history when selected again", () => {
const input = setup()
try {
const first = input.cache()
input.setState("id", "ses_b")
const second = input.cache()
expect(second).not.toBe(first)
expect(input.views.get("ses_a")!.active()).toBe(false)
expect(input.views.get("ses_a")!.element.dataset.messages).toBe("msg_a")
expect(input.views.get("ses_b")!.element.dataset.messages).toBe("msg_b")
input.setState("messages", "ses_a", [
{ id: "msg_c", type: "user", text: "Updated while inactive", time: { created: 3 } },
])
input.setState("id", "ses_a")
expect(input.cache()).toBe(first)
expect(input.views.get("ses_a")!.active()).toBe(true)
expect(input.views.get("ses_a")!.element.dataset.messages).toBe("msg_c")
expect(input.views.get("ses_b")!.element.dataset.messages).toBe("msg_b")
expect(input.disposed).toEqual([])
} finally {
input.dispose()
}
expect(input.disposed.sort()).toEqual(["ses_a", "ses_b"])
})
test("suspends a detached mobile view and reuses it when the conversation returns", () => {
const input = setup()
try {
const first = input.cache()
input.setState("visible", false)
expect(input.views.get("ses_a")!.active()).toBe(false)
input.setState("visible", true)
expect(input.cache()).toBe(first)
expect(input.views.get("ses_a")!.active()).toBe(true)
} finally {
input.dispose()
}
})
test("disposes views whose Location-scoped providers no longer match", () => {
const input = setup()
try {
const first = input.cache()
input.setState("directory", "/other")
expect(input.cache()).not.toBe(first)
expect(input.disposed).toEqual(["ses_a"])
input.setState("directory", "/repo")
expect(input.cache()).not.toBe(first)
expect(input.disposed).toEqual(["ses_a", "ses_a"])
} finally {
input.dispose()
}
expect(input.disposed).toHaveLength(3)
})
test("disposes views on workspace changes while the destination is not rendered", () => {
const input = setup()
try {
const first = input.cache()
input.setState("visible", false)
input.setState("directory", "/other")
expect(input.disposed).toEqual(["ses_a"])
input.setState("directory", "/repo")
input.setState("visible", true)
expect(input.cache()).not.toBe(first)
} finally {
input.dispose()
}
expect(input.disposed).toEqual(["ses_a", "ses_a"])
})
for (const order of ["session-first", "workspace-first"] as const) {
test(`keeps views live across five workspaces when updates are ${order}`, () => {
const input = setup()
const render = createRoot((dispose) => ({ selected: createMemo(input.cache), dispose }))
const visited = ["ses_a"]
try {
;["ses_b", "ses_c", "ses_d", "ses_e", "ses_a", "ses_c", "ses_b", "ses_e", "ses_d", "ses_a"].forEach(
(id, index) => {
batch(() => {
if (order === "workspace-first") input.setState("directory", `/repo/${id}`)
input.setState("id", id)
if (order === "session-first") input.setState("directory", `/repo/${id}`)
})
expect(input.disposed).toEqual(visited)
input.setState("messages", id, [
{ id: `msg_live_${index}`, type: "user", text: "Live update", time: { created: index + 3 } },
])
expect((render.selected() as HTMLDivElement).dataset.messages).toBe(`msg_live_${index}`)
expect(input.views.get(id)!.active()).toBe(true)
visited.push(id)
},
)
} finally {
render.dispose()
input.dispose()
}
})
}
test("evicts the least recently selected view and disposes all retained owners", () => {
const input = setup()
try {
const first = input.cache()
Array.from({ length: 15 }, (_, index) => `ses_${index}`).forEach((id) => {
input.setState("id", id)
input.cache()
})
input.setState("id", "ses_a")
expect(input.cache()).toBe(first)
input.setState("id", "ses_last")
input.cache()
expect(input.disposed).toEqual(["ses_0"])
input.setState("id", "ses_0")
input.cache()
expect(input.disposed).toEqual(["ses_0", "ses_1"])
} finally {
input.dispose()
}
expect(input.disposed).toHaveLength(18)
})
+1 -5
View File
@@ -100,11 +100,7 @@ function packageNames() {
function copyBinary(source) {
if (!fs.existsSync(source)) throw new Error(`Binary not found at ${source}`)
fs.mkdirSync(path.dirname(targetBinary), { recursive: true })
if (fs.existsSync(targetBinary)) {
try {
fs.unlinkSync(targetBinary)
} catch {}
}
if (fs.existsSync(targetBinary)) fs.unlinkSync(targetBinary)
try {
fs.linkSync(source, targetBinary)
} catch {
+1 -20
View File
@@ -119,26 +119,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
commands: [
Spec.make("agents", { description: "List all agents" }),
Spec.make("config", { description: "List configuration sources" }),
Spec.make("paths", {
description: "Show global paths (data, config, cache, state)",
params: {
name: Argument.choice("name", [
"db",
"home",
"data",
"config",
"cache",
"state",
"tmp",
"bin",
"log",
"repos",
]).pipe(
Argument.withDescription("Print only one path: db, home, data, config, cache, state, tmp, bin, log, repos"),
Argument.optional,
),
},
}),
Spec.make("paths", { description: "Show global paths (data, config, cache, state)" }),
],
}),
Spec.make("auth", {
@@ -1,21 +1,15 @@
import { EOL } from "os"
import { Effect, Option } from "effect"
import { Effect } from "effect"
import { Global } from "@opencode/util/global"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { databasePath } from "../../../database-path"
export default Runtime.handler(
Commands.commands.debug.commands.paths,
Effect.fn("cli.debug.paths")(function* (input) {
Effect.fn("cli.debug.paths")(function* () {
const global = yield* Global.Service
const paths = { ...global, db: databasePath(global.data) }
if (Option.isSome(input.name)) {
process.stdout.write(paths[input.name.value] + EOL)
return
}
process.stdout.write(
Object.entries(paths)
Object.entries(global)
.map(([key, value]) => `${key.padEnd(10)} ${value}${EOL}`)
.join(""),
)
-13
View File
@@ -1,13 +0,0 @@
import path from "node:path"
import { OPENCODE_CHANNEL } from "./version"
export function databasePath(data: string) {
const filename =
process.env.OPENCODE_DB ??
(["latest", "dev", "beta", "next", "prod"].includes(OPENCODE_CHANNEL) ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
? "opencode.db"
: `opencode-${OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`)
return filename === ":memory:" ? filename : path.resolve(data, filename)
}
+7 -2
View File
@@ -15,7 +15,6 @@ import { ServiceConfig } from "./services/service-config"
import { ServiceRegistration } from "./services/service-registration"
import { Updater } from "./services/updater"
import { WebUi } from "./services/web-ui"
import { databasePath } from "./database-path"
export type Mode = "default" | "service" | "stdio"
@@ -95,7 +94,13 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
pty: { handoff },
simulation: truthy(process.env.OPENCODE_SIMULATE),
database: {
path: databasePath(global.data),
path:
process.env.OPENCODE_DB ??
(["latest", "dev", "beta", "next", "prod"].includes(OPENCODE_CHANNEL) ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
? "opencode.db"
: `opencode-${OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`),
},
models: {
url: process.env.OPENCODE_MODELS_URL,
+2 -7
View File
@@ -167,11 +167,6 @@ const make = Effect.gen(function* () {
const latest = () => release().pipe(Effect.map((data) => data.version))
const temporaryDirectory = (prefix: string) =>
Effect.acquireRelease(fs.makeTempDirectory({ directory: global.cache, prefix }), (directory) =>
fs.remove(directory, { recursive: true, force: true }).pipe(Effect.ignore),
)
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/, "")
@@ -197,12 +192,12 @@ const make = Effect.gen(function* () {
if (method === "bun") {
// Bun does not prune old versions from its shared package cache.
yield* fs.makeDirectory(global.cache, { recursive: true })
const cache = yield* temporaryDirectory("update-")
const cache = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
return yield* exec(["bun", "install", "--global", "--trust", "--cache-dir", cache, target], "5 minutes")
}
if (method === "curl") {
yield* fs.makeDirectory(global.cache, { recursive: true })
const directory = yield* temporaryDirectory("update-")
const directory = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
const installer = path.join(directory, "install")
const download = yield* exec(
["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"],
+1 -21
View File
@@ -2,7 +2,7 @@ import { NodeServices } from "@effect/platform-node"
import { Global } from "@opencode/util/global"
import { AppProcess } from "@opencode/util/process"
import { expect, spyOn, test } from "bun:test"
import { Effect, FileSystem, PlatformError, Stream } from "effect"
import { Effect, FileSystem, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { existsSync } from "node:fs"
import path from "node:path"
@@ -18,7 +18,6 @@ function fixture(
error?: AppProcess.AppProcessError
} = () => ({}),
name = "@opencode/cli",
failCleanup = false,
) {
return Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
@@ -58,17 +57,6 @@ function fixture(
Effect.provideService(Global.Service, global),
Effect.provideService(FileSystem.FileSystem, {
...fs,
remove: (target, options) =>
failCleanup && target.startsWith(global.cache)
? Effect.fail(
PlatformError.systemError({
_tag: "PermissionDenied",
module: "FileSystem",
method: "remove",
pathOrDescriptor: target,
}),
)
: fs.remove(target, options),
realPath: (input) => (input === process.execPath ? Effect.succeed(executable) : fs.realPath(input)),
}),
Effect.provideService(
@@ -137,14 +125,6 @@ installs.forEach(({ method, command }) => {
}),
)
})
it.live("bun ignores install cache cleanup failures", () =>
Effect.gen(function* () {
const test = yield* fixture(() => ({}), "@opencode/cli", true)
yield* test.updater.upgrade("bun", "v2.3.4-beta.1")
expect(test.commands).toHaveLength(1)
}),
)
;["success", "download", "install"].forEach((failure) => {
it.live(`curl uses the V2 installer and cleans its directory: ${failure}`, () =>
Effect.gen(function* () {
-12
View File
@@ -1166,18 +1166,6 @@ export type MessageListInput = {
readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined
readonly cursor?: string | undefined
readonly type?:
| "agent-switched"
| "model-switched"
| "location-switched"
| "user"
| "synthetic"
| "system"
| "skill"
| "shell"
| "assistant"
| "compaction"
| undefined
}
export type MessageListOutput = {
readonly data: ReadonlyArray<SessionMessage.Info>
@@ -757,7 +757,7 @@ const EndpointMessageList = (raw: RawClient["server.message"]) => (input: Messag
preserveEffect<MessageListOutput>()(
raw["session.messages"]({
params: { sessionID: input["sessionID"] },
query: { limit: input["limit"], order: input["order"], cursor: input["cursor"], type: input["type"] },
query: { limit: input["limit"], order: input["order"], cursor: input["cursor"] },
}).pipe(Effect.mapError(mapClientError)),
)
@@ -1015,7 +1015,7 @@ export function make(options: ClientOptions) {
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/message`,
query: { limit: input["limit"], order: input["order"], cursor: input["cursor"], type: input["type"] },
query: { limit: input["limit"], order: input["order"], cursor: input["cursor"] },
successStatus: 200,
declaredStatuses: [400, 401, 404, 500],
empty: false,
@@ -1369,7 +1369,6 @@ export type ProviderInfo = {
activation: "auto" | "enabled" | "disabled"
package: string
compaction?: ProviderCompaction
websocket?: boolean
settings?: { [x: string]: any }
headers?: { [x: string]: string }
body?: { [x: string]: any }
@@ -1850,7 +1849,6 @@ export type ModelInfo = {
compatibility?: ModelCompatibility
package?: string
compaction?: ProviderCompaction
websocket?: boolean
settings?: { [x: string]: any }
headers?: { [x: string]: string }
body?: { [x: string]: any }
@@ -2027,7 +2025,6 @@ export type ConfigEntry =
providers?: {
[x: string]: {
compaction?: ProviderCompaction
websocket?: boolean
canonical?: string
name?: string
env?: Array<string>
@@ -2038,7 +2035,6 @@ export type ConfigEntry =
models?: {
[x: string]: {
compaction?: ProviderCompaction
websocket?: boolean
modelID?: string
family?: string
name?: string
@@ -4348,70 +4344,17 @@ export type MessageListInput = {
readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined
readonly cursor?: string | undefined
readonly type?:
| "agent-switched"
| "model-switched"
| "location-switched"
| "user"
| "synthetic"
| "system"
| "skill"
| "shell"
| "assistant"
| "compaction"
| undefined
}["limit"]
readonly order?: {
readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined
readonly cursor?: string | undefined
readonly type?:
| "agent-switched"
| "model-switched"
| "location-switched"
| "user"
| "synthetic"
| "system"
| "skill"
| "shell"
| "assistant"
| "compaction"
| undefined
}["order"]
readonly cursor?: {
readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined
readonly cursor?: string | undefined
readonly type?:
| "agent-switched"
| "model-switched"
| "location-switched"
| "user"
| "synthetic"
| "system"
| "skill"
| "shell"
| "assistant"
| "compaction"
| undefined
}["cursor"]
readonly type?: {
readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined
readonly cursor?: string | undefined
readonly type?:
| "agent-switched"
| "model-switched"
| "location-switched"
| "user"
| "synthetic"
| "system"
| "skill"
| "shell"
| "assistant"
| "compaction"
| undefined
}["type"]
}
export type MessageListOutput = SessionMessagesResponse
+4 -12
View File
@@ -57,8 +57,6 @@ type OpenCodeEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent
export type CreateDataInput = {
readonly api: () => OpenCodeClient
readonly directory: string
/** Raw-message window used for an initial transcript read. Older pages retain their normal size. */
readonly initialMessageLimit?: () => number
readonly event: {
readonly on: <Type extends OpenCodeEvent["type"]>(
type: Type,
@@ -1571,11 +1569,7 @@ export function createData(config: CreateDataInput) {
},
sync(sessionID: string) {
return sync.run(`session.message:${sessionID}`, async () => {
const response = await api().message.list({
sessionID,
limit: config.initialMessageLimit?.() ?? messagePageLimit,
order: "desc",
})
const response = await api().message.list({ sessionID, limit: messagePageLimit, order: "desc" })
const fetched = response.data.toReversed()
// Same protection as the pending sync: a re-fetch racing an
// admission must not wipe its local transcript row.
@@ -1589,11 +1583,9 @@ export function createData(config: CreateDataInput) {
(item) => !ids.has(item.id) && (outbox.has(item.id) || admitted.has(item.id)),
)
const messages = local.length === 0 ? fetched : [...fetched, ...local]
batch(() => {
messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])))
setStore("session", "message", sessionID, reconcile(messages))
setStore("session", "messageCursor", sessionID, response.cursor.next ?? undefined)
})
messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])))
setStore("session", "message", sessionID, reconcile(messages))
setStore("session", "messageCursor", sessionID, response.cursor.next ?? undefined)
})
},
more(sessionID: string) {
-38
View File
@@ -14,44 +14,6 @@ const session = (viewed: number): SessionInfo => ({
location: { directory: "/project" },
})
test("uses the configured initial window and retains normal cursor page sizes", async () => {
const requests: { limit: string | null; cursor: string | null }[] = []
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const url = new URL((input instanceof Request ? input : new Request(input, init)).url)
const cursor = url.searchParams.get("cursor")
requests.push({ limit: url.searchParams.get("limit"), cursor })
return Response.json({
data: [{ id: cursor ? "msg_1" : "msg_2", type: "user", text: "History", time: { created: cursor ? 1 : 2 } }],
cursor: cursor ? {} : { next: "older" },
})
},
})
const setup = createRoot((dispose) => ({
data: createData({
api: () => api,
directory: "/project",
initialMessageLimit: () => 40,
event: { on: () => () => {}, listen: () => () => {} },
}),
dispose,
}))
try {
await setup.data.session.message.sync("ses_refresh")
await setup.data.session.message.sync("ses_refresh")
expect(requests).toEqual([{ limit: "40", cursor: null }])
await setup.data.session.message.loadMore("ses_refresh")
expect(requests).toEqual([
{ limit: "40", cursor: null },
{ limit: "20", cursor: "older" },
])
expect(setup.data.session.message.list("ses_refresh").map((message) => message.id)).toEqual(["msg_1", "msg_2"])
} finally {
setup.dispose()
}
})
test("revalidates after an event overtakes an active session read", async () => {
let release!: () => void
const gate = new Promise<void>((resolve) => (release = resolve))
+25 -47
View File
@@ -31,8 +31,8 @@ ultimate source of truth.
- [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] 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] Object literals with shorthand, computed string/number keys, and spread from plain data objects; `null` and
`undefined` are no-ops, while arrays are rejected.
- [x] Template literals with interpolation.
- [x] Regular-expression literals.
- [x] `NaN` and `Infinity` globals.
@@ -44,21 +44,18 @@ ultimate source of truth.
## Bindings and destructuring
- [x] `const`, `let`, and `var` declarations.
- [x] `const`, `let`, and accepted `var` declarations.
- [x] Object and array destructuring in declarations, parameters, assignment expressions, and `for...of` bindings.
- [x] Nested patterns, defaults, elisions, and rest elements.
- [x] Assignment to identifiers, plain-object fields, non-negative integer array indexes, and writable URL
- [x] Assignment to identifiers, unblocked plain-object fields, non-negative integer array indexes, and writable URL
fields.
- [x] Direct function declarations are hoisted in program and block statement lists.
- [x] Parameter defaults observe a temporal dead zone for later parameters.
- [x] `var` is function-scoped and hoisted: names declared anywhere in a function or program body, including loop
heads, blocks, `switch` cases, and `try`/`catch`, read as `undefined` before their statement runs; redeclaration
assigns the one binding; a same-named parameter keeps its argument; closures in parameter defaults see outer
names rather than body `var`s.
- [ ] JavaScript-correct function scoping, hoisting, and redeclaration for accepted `var` declarations.
- [x] Predeclare `let` and `const` bindings in every lexical scope, including program/block bodies, switch bodies, and
loop headers, so reads before initialization and self- or cross-referential initializers observe the JavaScript
temporal dead zone.
- [x] Function declarations are hoisted across all cases of a `switch`, like any other statement list.
- [ ] Hoist function declarations accepted directly in switch cases.
- [x] Computed object destructuring keys such as `const { [field]: value } = record`.
- [x] Object destructuring from arrays, such as `const { length } = values`.
- [x] Array binding and assignment destructuring from strings, Maps, Sets, URLSearchParams, custom synchronous
@@ -73,7 +70,7 @@ ultimate source of truth.
- [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...in` over own keys of plain objects, arrays, strings, and tool references; other values iterate nothing.
- [x] `for...in` over own keys of plain objects, arrays, and tool references.
- [x] Unlabeled `break` and `continue`.
- [x] `try`, `catch`, optional catch bindings, and `finally`.
- [x] `throw` with arbitrary values.
@@ -141,10 +138,7 @@ ultimate source of truth.
- [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.
- [x] `new` for Array, Object, Error types, Date, RegExp, Map, Set, URL, URLSearchParams, and Promise.
- [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`.
- [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`.
- [x] Bitwise operators: `&`, `|`, `^`, `~`, `<<`, `>>`, and `>>>`.
@@ -213,16 +207,12 @@ ultimate source of truth.
primitive wrapper objects (`Object(1)`) are rejected explicitly.
- [x] Computed property names and object spread.
- [x] `Object.keys`, `Object.values`, `Object.entries`, `Object.hasOwn`, `Object.assign`, and `Object.fromEntries`, with
synchronous iterator support for `fromEntries`. Sources follow ToObject: strings enumerate by index, other
primitives and wrappers contribute nothing, and `null`/`undefined` throw. `Object.assign` accepts array
targets for index keys only; a primitive target is a `TypeError` rather than a boxed object.
synchronous iterator support for `fromEntries`.
- [x] `Object.keys` over arrays and tool references.
- [x] Object identity is preserved by in-CodeMode Object helpers.
- [x] `__proto__`, `constructor`, and `prototype` are ordinary own data keys. `x.constructor` without an own key resolves
to the owning built-in (`[].constructor === Array`, `new TypeError().constructor === TypeError`); prototype objects
are not observable, so `[].__proto__` and `Object.prototype` read as `undefined` and `o.__proto__ = x` sets an own
field.
- [x] Circular references are rejected when created (`o.self = o`, `array.push(array)`), not at serialization as in JS.
- [x] Prototype traversal and mutation through `__proto__`, `constructor`, and `prototype` are blocked.
- [ ] Legal own data fields named `__proto__`, `constructor`, or `prototype` are rejected at JSON/tool boundaries and
cannot be created, read, or written in CodeMode; tool path segments with those names remain supported.
- [x] `Object.is` for supported data values.
- [x] `Object.groupBy` over finite collections and custom synchronous iterators/generators, with string-key coercion
and null-prototype results.
@@ -253,13 +243,12 @@ ultimate source of truth.
## Strings
- [x] Case/normalization: `toLowerCase`, `toUpperCase`, `normalize`.
- [x] Trimming: `trim`, `trimStart`, and `trimEnd`, plus the Annex B `trimLeft` and `trimRight` aliases.
- [x] Trimming: `trim`, `trimStart`, and `trimEnd`.
- [x] Searching/tests: `includes`, `startsWith`, `endsWith`, `indexOf`, `lastIndexOf`, and `search`.
- [x] Slicing/access: `slice`, `substring`, Annex B `substr`, `at`, `charAt`, `charCodeAt`, and `codePointAt`.
- [x] Slicing/access: `slice`, `substring`, `at`, `charAt`, `charCodeAt`, and `codePointAt`.
- [x] Construction/transformation: `split`, `concat`, `repeat`, `padStart`, `padEnd`, `replace`, and `replaceAll`.
- [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] Static `String.fromCharCode` and `String.fromCodePoint`.
- [x] Native argument coercion for supported String methods; for example, `includes(1)` and `slice("1")` coerce like
@@ -287,20 +276,23 @@ ultimate source of truth.
use their epoch time) and reject opaque runtime references as data errors.
- [x] Unknown static members on global namespaces and on `Number`/`String`/the coercion functions read as `undefined`
for feature detection. Calling any undefined value reports a native-style `TypeError` naming the callee, for
example `Math.sum is not a function.` Unknown `Promise` statics keep their descriptive error.
example `Math.sum is not a function.` Blocked members (`constructor`, `__proto__`, ...) still throw,
and unknown `Promise` statics keep their descriptive error.
- [x] `Math.sumPrecise` over finite collections and custom synchronous iterators/generators, rejecting non-number
elements without coercion.
- [x] Global coercing `isFinite` and `isNaN`; opaque runtime references reject as data errors, like `Number(...)`.
## JSON and console
- [x] `JSON.parse` and `JSON.stringify` for supported data objects.
- [x] `JSON.parse` and `JSON.stringify` for supported data objects; the blocked data-key gap listed above still applies.
- [x] Numeric/string indentation for `JSON.stringify`.
- [x] `JSON.parse` reviver callbacks, including postorder traversal, deletion through `undefined`, and root replacement.
Revivers receive `(key, value)` but no `this` holder because CodeMode functions intentionally have no `this`.
- [x] `JSON.stringify` function and array replacers. Function replacers receive `(key, value)` in preorder, including
the root, but no `this` holder. Array replacers preserve requested property order, deduplicate names, coerce
number primitives, and ignore non-string/non-number entries. Primitive wrapper entries remain unsupported.
- [x] JSON callbacks retain the blocked-key boundary: parsed or stringified data containing `__proto__`, `constructor`,
or `prototype` is rejected before callback traversal.
- [x] Captured `console.log`, `console.info`, `console.debug`, `console.warn`, and `console.error`.
- [x] Captured `console.dir` and `console.table`.
@@ -320,7 +312,6 @@ ultimate source of truth.
- [x] Local and UTC Date setters, including native argument coercion, mutation, rollover, invalid-Date recovery, and
`TimeClip` behavior.
- [x] `Date.prototype.toUTCString` and its `toGMTString` alias.
- [x] `toDateString` and `toTimeString` in the host's local timezone.
- [x] Native one-argument Date coercion for supported values, including booleans, null, arrays, and plain objects.
- [x] Native Date loose-equality and default primitive-coercion semantics, using CodeMode's deterministic ISO string
representation for the string primitive.
@@ -332,7 +323,7 @@ ultimate source of truth.
- [x] `test`, `exec`, and `toString`.
- [x] Readable `source`, `flags`, `lastIndex`, `hasIndices`, `global`, `ignoreCase`, `multiline`, `sticky`, `unicode`,
`unicodeSets`, and `dotAll`.
- [x] Captures, named groups, match `.index`, and stateful global matching.
- [x] Captures, safe named groups (blocked member names are omitted), match `.index`, and stateful global matching.
- [x] Integration with supported String methods, including function replacers.
- [x] Writable `lastIndex`.
- [x] Match `indices` metadata for the `d` flag, including named groups on `exec`, `match`, and `matchAll` results.
@@ -364,33 +355,20 @@ ultimate source of truth.
`entries`, `toString`, and `size`.
- [x] URL values serialize to their href; URLSearchParams serialize to `{}`.
## Web platform helpers
- [x] `atob` and `btoa` with forgiving-base64 decoding and WebIDL string conversion; invalid input throws an Error
named `InvalidCharacterError`, since there is no `DOMException`.
- [x] `crypto.randomUUID()`.
- [x] `structuredClone` over the data model: objects, arrays with holes, Date, RegExp (`lastIndex` reset), Map, Set,
URL, URLSearchParams, and Errors (name, message, and cause only); shared references stay shared within one
clone; functions, promises, and tool references throw an Error named `DataCloneError`.
- [ ] `crypto.getRandomValues` and `crypto.subtle`, `TextEncoder`/`TextDecoder`, and `Blob`: these need a binary
value type, which the JSON-like data model does not have yet.
## Errors and diagnostics
- [x] `Error`, `TypeError`, `RangeError`, `SyntaxError`, `ReferenceError`, `EvalError`, and `URIError`, callable with
or without `new`.
- [x] `AggregateError` with the `(errors, message?)` signature and an own `errors` array, constructed directly or by
an all-rejected `Promise.any`; direct construction accepts custom synchronous iterators and generators.
- [x] Error `name`/`message`, error inheritance through `instanceof`, and plain-data serialization. Errors have no
`stack`; the diagnostic carries the source location instead.
- [x] Error `name`/`message`, error inheritance through `instanceof`, and plain-data serialization.
- [x] `instanceof` for Date, RegExp, Map, Set, URL, URLSearchParams, Array, Object, Promise, and Error types.
- [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.
shift them.
- [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.
This is deliberate: the program should handle a failure the same way regardless of where it originated.
- [ ] Distinguish user-thrown failures from interpreter defects and explicit tool refusals from internal tool
failures; preserve those categories in caught errors, promise rejection handlers, and `Promise.allSettled`
reasons.
+2 -2
View File
@@ -105,7 +105,7 @@ export type Result = typeof Result.Type
/** Reusable confined runtime over explicit tools. */
export type Runtime<R = never> = {
readonly catalog: ReadonlyArray<ToolDescription>
readonly catalog: () => ReadonlyArray<ToolDescription>
readonly execute: (code: string) => Effect.Effect<Result, never, R>
}
@@ -134,7 +134,7 @@ export const make = <const Provided extends Record<string, unknown> = {}>(
const prepared = ToolRuntime.prepare((options.tools ?? {}) as Tools<Services<Provided>>)
const limits = resolveExecutionLimits(options.limits)
return {
catalog: prepared.catalog,
catalog: () => prepared.catalog,
execute: (code) => executeProgram(code, prepared, limits, options),
}
}
+12 -8
View File
@@ -22,6 +22,10 @@ export class ToolRuntimeError extends Error {
}
}
const blockedMemberNames = new Set(["__proto__", "constructor", "prototype"])
export const isBlockedMember = (name: string): boolean => blockedMemberNames.has(name)
/**
* Brings a host-produced runtime value into the program: runtime values pass through, their host
* counterparts (Date, RegExp, Map, Set, URL, URLSearchParams) are wrapped, and objects become
@@ -114,7 +118,10 @@ const copy = (value: unknown, label: string, mode: Mode, depth: number, seen: Se
if (mode === "program") {
for (const [key, item] of Object.entries(value)) {
if (Object.hasOwn(copied, key)) continue
define(copied, key, copy(item, label, mode, depth + 1, seen))
if (isBlockedMember(key)) {
throw new ToolRuntimeError("InvalidDataValue", `${label} contains blocked property '${key}'.`)
}
Reflect.set(copied, key, copy(item, label, mode, depth + 1, seen))
}
}
seen.delete(value)
@@ -128,16 +135,13 @@ const copy = (value: unknown, label: string, mode: Mode, depth: number, seen: Se
const copied: SafeObject = plain ? (Object.create(null) as SafeObject) : {}
for (const [key, item] of Object.entries(value)) {
if (isBlockedMember(key)) {
throw new ToolRuntimeError("InvalidDataValue", `${label} contains blocked property '${key}'.`)
}
const next = copy(item, label, mode, depth + 1, seen)
if (next === undefined && mode === "json") continue
define(copied, key, next)
copied[key] = next
}
seen.delete(value)
return copied
}
// Own data property regardless of the target's prototype, so a "__proto__" key on a host object or
// array never reaches the Object.prototype setter.
export const define = (target: object, key: string, value: unknown): void => {
Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true })
}
@@ -11,7 +11,6 @@ import { regexpGlobal } from "../stdlib/regexp.js"
import { stringGlobal } from "../stdlib/string.js"
import { uriGlobal, urlGlobal, urlSearchParamsGlobal } from "../stdlib/url.js"
import { coercion, errorConstructors } from "../stdlib/value.js"
import { atobGlobal, btoaGlobal, cryptoGlobal, structuredCloneGlobal } from "../stdlib/web.js"
import { ToolReference } from "../tool-runtime.js"
import { errorGlobal } from "./errors.js"
import { HostFunction } from "./host.js"
@@ -72,9 +71,5 @@ export const globals = <R>(host: Host<R>): ReadonlyArray<readonly [string, unkno
["encodeURIComponent", uriGlobal("encodeURIComponent")],
["decodeURI", uriGlobal("decodeURI")],
["decodeURIComponent", uriGlobal("decodeURIComponent")],
["atob", atobGlobal],
["btoa", btoaGlobal],
["crypto", cryptoGlobal],
["structuredClone", structuredCloneGlobal],
...[...errorConstructors].map((name) => [name, errorGlobal(name, host.runner)] as const),
]
+2 -13
View File
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { type SafeObject, toProgram } from "../data.js"
import { isBlockedMember, type SafeObject, toProgram } from "../data.js"
import { dateSetterArgumentCount, invokeDateMethod } from "../stdlib/date.js"
import { invokeNumberMethod } from "../stdlib/number.js"
import { invokeRegExpMethod, matchToValue, toHostRegex } from "../stdlib/regexp.js"
@@ -118,11 +118,9 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
result = value.trim()
break
case "trimStart":
case "trimLeft":
result = value.trimStart()
break
case "trimEnd":
case "trimRight":
result = value.trimEnd()
break
// Locale/options are deliberately unsupported; comparison uses the host default locale.
@@ -243,15 +241,6 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
case "substring":
result = value.substring(optNum(0) ?? 0, optNum(1))
break
case "substr":
result = value.substr(optNum(0) ?? 0, optNum(1))
break
case "isWellFormed":
result = value.isWellFormed()
break
case "toWellFormed":
result = value.toWellFormed()
break
case "charCodeAt":
result = value.charCodeAt(optNum(0) ?? 0)
break
@@ -291,7 +280,7 @@ const invokeStringReplacer = <R>(
if (hasGroups) {
const safeGroups: SafeObject = Object.create(null) as SafeObject
for (const [key, group] of Object.entries(groups)) {
safeGroups[key] = group
if (!isBlockedMember(key)) safeGroups[key] = group
}
callbackArgs[callbackArgs.length - 1] = safeGroups
}
+3 -4
View File
@@ -88,6 +88,9 @@ export class GeneratorReturn {
export const OptionalShortCircuit: unique symbol = Symbol("codemode.optional-short-circuit")
export const supportedSyntaxMessage =
"Supported orchestration syntax: tools.* calls (they return promises - resolve them with await), data literals, destructuring, optional chaining, template literals, conditionals, switch, loops (incl. for...of and for...in over object/array/tools keys), arrow functions, spread, try/catch, array methods (map/filter/find/findIndex/some/every/reduce/flatMap/forEach/sort/slice/concat/indexOf/lastIndexOf/at/flat/reverse/includes/join), string methods (incl. match/matchAll/replace/split with regular expressions), Date/RegExp/Map/Set/URL/URLSearchParams, URI encoding helpers, Object/Math/JSON helpers, captured console.log/warn/error/dir/table, Promise.all/allSettled/race/any/resolve/reject over arrays mixing promises and plain values for parallel tool calls, promise chaining with .then/.catch/.finally, and new Promise((resolve, reject) => ...) construction."
export class InterpreterRuntimeError extends Error {
readonly node?: AstNode
errorName = "Error"
@@ -109,10 +112,6 @@ export class InterpreterRuntimeError extends Error {
}
}
// Orient the agent rather than enumerate JavaScript; interpreter-support.md is the full matrix.
export const supportedSyntaxMessage =
"This is a restricted JavaScript-like language. Supported: plain and async functions, data literals, destructuring, standard control flow, await and Promise, and built-ins such as Array, Object, Math, JSON, Date, RegExp, Map, Set, and URL. Unsupported: classes, this, getters/setters, tagged templates, BigInt, and custom Symbols. Use plain functions and data objects instead."
export const unsupportedSyntax = (kind: string, node: AstNode): InterpreterRuntimeError =>
new InterpreterRuntimeError(
`Syntax '${kind}' is not supported. ${supportedSyntaxMessage}`,
+22 -24
View File
@@ -32,17 +32,6 @@ export class PromiseRuntime<R> {
constructor(private readonly scope: Scope.Scope) {}
// Resolution bodies need the promise's own identity to reject `resolve(promise)` self-resolution.
createWithSelf(
body: (self: { promise?: Values.Promise }) => Effect.Effect<unknown, unknown, R>,
): Effect.Effect<Values.Promise, never, R> {
const self: { promise?: Values.Promise } = {}
return Effect.map(this.create(body(self)), (promise) => {
self.promise = promise
return promise
})
}
create(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<Values.Promise, never, R> {
return Effect.suspend(() => {
// Allocate before forking so reruns get distinct IDs and diagnostics retain creation order.
@@ -137,7 +126,11 @@ export const resolvePromise = <R>(
node: AstNode,
): Effect.Effect<Values.Promise, never, R> => {
if (value instanceof Values.Promise) return Effect.succeed(value)
return promises.createWithSelf((self) => resolvePromiseValue(runner, value, node, self))
const box: { promise?: Values.Promise } = {}
return Effect.map(promises.create(resolvePromiseValue(runner, value, node, box)), (promise) => {
box.promise = promise
return promise
})
}
const promiseStatics = ["all", "allSettled", "race", "any", "resolve", "reject"] as const
@@ -261,9 +254,11 @@ const constructPromise = <R>(
}
return Effect.gen(function* () {
const deferred = Deferred.makeUnsafe<unknown, unknown>()
const promise = yield* promises.createWithSelf((self) =>
Effect.flatMap(Deferred.await(deferred), (value) => resolvePromiseValue(runner, value, node, self)),
const box: { promise?: Values.Promise } = {}
const promise = yield* promises.create(
Effect.flatMap(Deferred.await(deferred), (value) => resolvePromiseValue(runner, value, node, box)),
)
box.promise = promise
const resolve = capability("resolve", (value) => Deferred.doneUnsafe(deferred, Exit.succeed(value)))
const reject = capability("reject", (value) => Deferred.doneUnsafe(deferred, Exit.fail(new ProgramThrow(value))))
const executed = yield* Effect.exit(runner.invokeFunction(executor, [resolve, reject]))
@@ -315,16 +310,19 @@ const chainReaction = <R>(
method: string,
node: AstNode,
): Effect.Effect<Values.Promise, never, R> => {
return promises.createWithSelf((self) =>
Effect.gen(function* () {
const exit = yield* reactionExit(promises, source)
const handler = Exit.isSuccess(exit) ? onFulfilled : onRejected
if (handler === undefined) return yield* exit
const input = Exit.isSuccess(exit) ? exit.value : caughtErrorValue(Cause.squash(exit.cause))
const result = yield* applyCollectionCallback(runner, handler, method, node)([input])
return yield* resolvePromiseValue(runner, result, node, self)
}),
)
const box: { promise?: Values.Promise } = {}
const body = Effect.gen(function* () {
const exit = yield* reactionExit(promises, source)
const handler = Exit.isSuccess(exit) ? onFulfilled : onRejected
if (handler === undefined) return yield* exit
const input = Exit.isSuccess(exit) ? exit.value : caughtErrorValue(Cause.squash(exit.cause))
const result = yield* applyCollectionCallback(runner, handler, method, node)([input])
return yield* resolvePromiseValue(runner, result, node, box)
})
return Effect.map(promises.create(body), (derived) => {
box.promise = derived
return derived
})
}
const chainFinally = <R>(
@@ -77,23 +77,6 @@ export const rejectCircularInsertion = (
}
}
export const describeValue = (value: unknown): string => {
if (value === null) return "null"
if (Array.isArray(value)) return "an array"
if (value instanceof Values.Promise) return "an un-awaited Promise"
if (value instanceof ToolReference) return "a tool reference"
if (value instanceof Values.Date) return "a Date"
if (value instanceof Values.RegExp) return "a RegExp"
if (value instanceof Values.Map) return "a Map"
if (value instanceof Values.Set) return "a Set"
if (value instanceof Values.URL) return "a URL"
if (value instanceof Values.URLSearchParams) return "a URLSearchParams"
if (value instanceof CodeModeGenerator) return "a generator"
if (isRuntimeReference(value)) return "a function"
if (typeof value === "object") return "a data object"
return `a ${typeof value}`
}
export const typeofValue = (value: unknown): string => {
if (
value instanceof HostFunction ||
@@ -108,12 +91,3 @@ export const typeofValue = (value: unknown): string => {
if (value instanceof ToolReference) return value.path.length > 0 ? "function" : "object"
return typeof value
}
const MAX_ARRAY_LENGTH = 4_294_967_295
export const parseArrayIndex = (key: string | number): number | undefined => {
const property = String(key)
if (!/^(0|[1-9]\d*)$/.test(property)) return undefined
const index = Number(property)
return index < MAX_ARRAY_LENGTH ? index : undefined
}
+156 -176
View File
@@ -41,7 +41,7 @@ import type {
YieldExpression,
} from "acorn"
import { Cause, Deferred, Effect, Exit } from "effect"
import { ToolRuntimeError, type SafeObject, toProgram } from "../data.js"
import { isBlockedMember, ToolRuntimeError, type SafeObject, toProgram } from "../data.js"
import { ToolReference } from "../tool-runtime.js"
import {
type AstNode,
@@ -71,14 +71,7 @@ import { HostFunction, HostNamespace } from "./host.js"
import { invokeIntrinsic } from "./methods.js"
import { preserveConsumerError, type Runner } from "./runner.js"
import { invokePromiseInstanceMethod, PromiseRuntime, resolvePromise, resolvePromiseValue } from "./promises.js"
import {
containsOpaqueReference,
describeValue,
isRuntimeReference,
parseArrayIndex,
rejectCircularInsertion,
typeofValue,
} from "./references.js"
import { containsOpaqueReference, isRuntimeReference, rejectCircularInsertion, typeofValue } from "./references.js"
import { ScopeStack } from "./scope.js"
import { arrayMethods, mapMethods, setMethods } from "../stdlib/collections.js"
import { dateMethods } from "../stdlib/date.js"
@@ -86,20 +79,16 @@ import { numberMethods } from "../stdlib/number.js"
import { constructRegExp, regexpMethods, regexpProperties } from "../stdlib/regexp.js"
import { stringMethods } from "../stdlib/string.js"
import { uriArgument, urlMethods, urlProperties, urlSearchParamsMethods, urlWritableProperties } from "../stdlib/url.js"
import { enumerableSource } from "../stdlib/object.js"
import { coerceToNumber, coerceToString, compoundOperators, errorBrandName } from "../stdlib/value.js"
import { coerceToNumber, coerceToString, compoundOperators } from "../stdlib/value.js"
import { Values } from "../values.js"
// What a loop does with its body's result: exit with a StatementResult, or undefined to keep iterating.
// Unlabelled break ends this loop; a label the loop does not carry propagates outward.
const loopExit = (result: StatementResult, labels: ReadonlySet<string> | undefined): StatementResult | undefined => {
if (result.kind === "return") return result
if (result.kind === "break") {
if (result.label !== undefined && !labels?.has(result.label)) return result
return { kind: "none" }
}
if (result.kind === "continue" && result.label !== undefined && !labels?.has(result.label)) return result
return undefined
const MAX_ARRAY_LENGTH = 4_294_967_295
const parseArrayIndex = (key: string | number): number | undefined => {
const property = String(key)
if (!/^(0|[1-9]\d*)$/.test(property)) return undefined
const index = Number(property)
return index < MAX_ARRAY_LENGTH ? index : undefined
}
const calleeDescription = (callee: Expression | Super | undefined): string => {
@@ -118,25 +107,6 @@ const calleeDescription = (callee: Expression | Super | undefined): string => {
return "The called value"
}
const hasOwn = (value: unknown, key: PropertyKey): boolean =>
value !== null && typeof value === "object" && Object.hasOwn(value, key)
const constructorName = (value: unknown): string | undefined => {
if (typeof value === "string") return "String"
if (typeof value === "number") return "Number"
if (typeof value === "boolean") return "Boolean"
if (Array.isArray(value)) return "Array"
if (value instanceof Values.Date) return "Date"
if (value instanceof Values.RegExp) return "RegExp"
if (value instanceof Values.Map) return "Map"
if (value instanceof Values.Set) return "Set"
if (value instanceof Values.URL) return "URL"
if (value instanceof Values.URLSearchParams) return "URLSearchParams"
if (value instanceof Values.Promise) return "Promise"
if (value === null || typeof value !== "object" || isRuntimeReference(value)) return undefined
return errorBrandName(value) ?? "Object"
}
const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean => {
if (rhs instanceof HostFunction && rhs.instanceOf !== undefined) return rhs.instanceOf(lhs)
throw new InterpreterRuntimeError(
@@ -170,51 +140,6 @@ const collectPatternNames = (pattern: Pattern, out: Array<string> = []): Array<s
return out
}
// `var` names declared anywhere in a function body except inside nested functions, which own theirs.
// Memoized per body: a function's var names never change, and hoisting runs on every call.
const varNames = new WeakMap<ReadonlyArray<Statement | ModuleDeclaration>, ReadonlyArray<string>>()
const collectVarNames = (
node: Statement | ModuleDeclaration | null | undefined,
out: Array<string> = [],
): Array<string> => {
if (!node) return out
switch (node.type) {
case "VariableDeclaration":
if (node.kind === "var") for (const declaration of node.declarations) collectPatternNames(declaration.id, out)
break
case "BlockStatement":
for (const statement of node.body) collectVarNames(statement, out)
break
case "IfStatement":
collectVarNames(node.consequent, out)
collectVarNames(node.alternate, out)
break
case "ForStatement":
if (node.init?.type === "VariableDeclaration") collectVarNames(node.init, out)
collectVarNames(node.body, out)
break
case "ForInStatement":
case "ForOfStatement":
if (node.left.type === "VariableDeclaration") collectVarNames(node.left, out)
collectVarNames(node.body, out)
break
case "WhileStatement":
case "DoWhileStatement":
case "LabeledStatement":
collectVarNames(node.body, out)
break
case "SwitchStatement":
for (const item of node.cases) for (const statement of item.consequent) collectVarNames(statement, out)
break
case "TryStatement":
collectVarNames(node.block, out)
collectVarNames(node.handler?.body, out)
collectVarNames(node.finalizer, out)
break
}
return out
}
const loopDeclaration = (left: VariableDeclaration | Pattern, statement: "for...of" | "for...in") => {
if (left.type !== "VariableDeclaration") return undefined
const declaration = left.declarations.length === 1 ? left.declarations[0] : undefined
@@ -275,8 +200,6 @@ const promiseResolutionNode: AstNode = { type: "PromiseResolution", start: 0, en
/** One program execution: the tool bridge, promise scheduler, captured logs, and the global scope built once. */
export class Runtime<R> {
readonly runner: Runner<R>
/** Built-in globals by name, unaffected by program shadowing. */
readonly builtins: ReadonlyMap<string, unknown>
private readonly root: Frame<R>
constructor(
@@ -295,8 +218,7 @@ export class Runtime<R> {
settlePromise: (promise) => this.root.settlePromise(promise),
syncIterator: (value, node) => this.root.syncIterator(value, node),
}
this.builtins = new Map(globals(this))
for (const [name, value] of this.builtins) globalScope.set(name, { mutable: false, value })
for (const [name, value] of globals(this)) globalScope.set(name, { mutable: false, value })
}
run(program: Program): Effect.Effect<unknown, unknown, R> {
@@ -321,7 +243,6 @@ class Frame<R> {
return Effect.gen(function* () {
self.predeclareLexical(program.body)
self.hoistFunctions(program.body)
self.hoistVars(program.body)
let value: unknown = undefined
for (const [index, statement] of program.body.entries()) {
if (index === program.body.length - 1 && statement.type === "ExpressionStatement") {
@@ -445,20 +366,6 @@ class Frame<R> {
}
}
// Hoisted `var` bindings start undefined, or copy a same-named parameter. Function bodies hoist
// into their own scope above the parameters so closures in parameter defaults keep seeing outer names.
private hoistVars(statements: ReadonlyArray<Statement | ModuleDeclaration>, parameters?: Map<string, Binding>): void {
const names =
varNames.get(statements) ??
statements.reduce<Array<string>>((out, statement) => collectVarNames(statement, out), [])
varNames.set(statements, names)
const scope = this.scopes.current()
for (const name of names) {
if (scope.has(name)) continue
scope.set(name, { mutable: true, value: parameters?.get(name)?.value, initialized: true })
}
}
private predeclareLexical(statements: ReadonlyArray<Statement | ModuleDeclaration>): void {
for (const statement of statements) {
if (statement.type !== "VariableDeclaration") continue
@@ -496,9 +403,7 @@ class Frame<R> {
self.scopes.push()
return yield* Effect.gen(function* () {
const cases = node.cases
const statements = cases.flatMap((branch) => branch.consequent)
self.predeclareLexical(statements)
self.hoistFunctions(statements)
self.predeclareLexical(cases.flatMap((branch) => branch.consequent))
let defaultIndex: number | undefined
let selected: number | undefined
for (const [index, branch] of cases.entries()) {
@@ -540,8 +445,21 @@ class Frame<R> {
const self = this
return Effect.gen(function* () {
while (yield* self.evaluateExpression(node.test)) {
const exit = loopExit(yield* self.evaluateStatement(node.body), labels)
if (exit !== undefined) return exit
const result = yield* self.evaluateStatement(node.body)
if (result.kind === "continue") {
if (result.label !== undefined && !labels?.has(result.label)) return result
continue
}
if (result.kind === "break") {
if (result.label !== undefined && !labels?.has(result.label)) return result
return { kind: "none" } satisfies StatementResult
}
if (result.kind === "return") {
return result
}
}
return { kind: "none" } satisfies StatementResult
@@ -555,8 +473,21 @@ class Frame<R> {
const self = this
return Effect.gen(function* () {
do {
const exit = loopExit(yield* self.evaluateStatement(node.body), labels)
if (exit !== undefined) return exit
const result = yield* self.evaluateStatement(node.body)
if (result.kind === "continue") {
if (result.label !== undefined && !labels?.has(result.label)) return result
continue
}
if (result.kind === "break") {
if (result.label !== undefined && !labels?.has(result.label)) return result
return { kind: "none" } satisfies StatementResult
}
if (result.kind === "return") {
return result
}
} while (yield* self.evaluateExpression(node.test))
return { kind: "none" } satisfies StatementResult
@@ -602,13 +533,27 @@ class Frame<R> {
nextIteration()
while (testNode ? yield* self.evaluateExpression(testNode) : true) {
const exit = loopExit(yield* self.evaluateStatement(node.body), labels)
if (exit !== undefined) return exit
const result = yield* self.evaluateStatement(node.body)
if (result.kind === "return") {
return result
}
if (result.kind === "break") {
if (result.label !== undefined && !labels?.has(result.label)) return result
return { kind: "none" } satisfies StatementResult
}
if (result.kind === "continue" && result.label !== undefined && !labels?.has(result.label)) return result
nextIteration()
if (updateNode) {
yield* self.evaluateExpression(updateNode)
}
if (result.kind === "continue") {
continue
}
}
return { kind: "none" } satisfies StatementResult
@@ -651,12 +596,10 @@ class Frame<R> {
const evaluateBody = (value: unknown) =>
Effect.gen(function* () {
if (declared?.lexical) {
if (declared) {
self.scopes.push()
self.predeclarePattern(declared.pattern, declared.mutable, left)
yield* self.declarePattern(declared.pattern, value, declared.mutable, left, true)
} else if (declared) {
yield* self.assignPattern(declared.pattern, value, left)
if (declared.lexical) self.predeclarePattern(declared.pattern, declared.mutable, left)
yield* self.declarePattern(declared.pattern, value, declared.mutable, left, declared.lexical)
} else if (assignment) {
yield* self.assignPattern(assignment, value, left)
}
@@ -664,7 +607,7 @@ class Frame<R> {
}).pipe(
Effect.ensuring(
Effect.sync(() => {
if (declared?.lexical) self.scopes.pop()
if (declared) self.scopes.pop()
}),
),
)
@@ -683,10 +626,22 @@ class Frame<R> {
}
return yield* Effect.failCause(bodyExit.cause)
}
const exit = loopExit(bodyExit.value, labels)
if (exit !== undefined) {
const result = bodyExit.value
if (result.kind === "return") {
yield* close()
return exit
return result
}
if (result.kind === "break") {
yield* close()
if (result.label !== undefined && !labels?.has(result.label)) return result
return { kind: "none" } satisfies StatementResult
}
if (result.kind === "continue" && result.label !== undefined && !labels?.has(result.label)) {
yield* close()
return result
}
}
}).pipe(
@@ -869,11 +824,17 @@ class Frame<R> {
throw new InterpreterRuntimeError(`${context} must be a function.`, node).as("TypeError")
}
// for...in over null/undefined iterates nothing, like JS.
private enumerableKeys(value: unknown, node: AstNode): Array<string> {
if (value instanceof ToolReference) return [...this.runtime.toolKeys(value.path)]
if (value === null || value === undefined) return []
return Object.keys(enumerableSource("for...in", value, node))
private enumerableKeys(value: unknown): Array<string> | undefined {
if (value instanceof ToolReference) {
return [...this.runtime.toolKeys(value.path)]
}
if (Array.isArray(value)) {
return Object.keys(value)
}
if (value !== null && typeof value === "object" && !isRuntimeReference(value)) {
return Object.keys(value)
}
return undefined
}
private evaluateForInStatement(
@@ -889,7 +850,13 @@ class Frame<R> {
if (declared?.lexical) self.predeclarePattern(declared.pattern, declared.mutable, left)
const right = yield* self.evaluateExpression(node.right)
const keys = self.enumerableKeys(right, node.right)
const keys = self.enumerableKeys(right)
if (keys === undefined) {
throw new InterpreterRuntimeError(
"for...in requires a plain object, array, or tools reference. Use for...of for arrays/strings/Maps/Sets, or Object.keys(value) for a key list.",
node,
)
}
if (left.type !== "Identifier" && left.type !== "VariableDeclaration") {
throw new InterpreterRuntimeError("Unsupported for...in binding.", left)
@@ -898,12 +865,10 @@ class Frame<R> {
for (const key of keys) {
const result = yield* Effect.gen(function* () {
if (declared?.lexical) {
if (declared) {
self.scopes.push()
self.predeclarePattern(declared.pattern, declared.mutable, left)
yield* self.declarePattern(declared.pattern, key, declared.mutable, left, true)
} else if (declared) {
yield* self.assignPattern(declared.pattern, key, left)
if (declared.lexical) self.predeclarePattern(declared.pattern, declared.mutable, left)
yield* self.declarePattern(declared.pattern, key, declared.mutable, left, declared.lexical)
} else if (assignmentName) {
self.scopes.set(assignmentName, key, left)
}
@@ -911,13 +876,24 @@ class Frame<R> {
}).pipe(
Effect.ensuring(
Effect.sync(() => {
if (declared?.lexical) self.scopes.pop()
if (declared) self.scopes.pop()
}),
),
)
const exit = loopExit(result, labels)
if (exit !== undefined) return exit
if (result.kind === "return") {
return result
}
if (result.kind === "break") {
if (result.label !== undefined && !labels?.has(result.label)) return result
return { kind: "none" } satisfies StatementResult
}
if (result.kind === "continue") {
if (result.label !== undefined && !labels?.has(result.label)) return result
continue
}
}
return { kind: "none" } satisfies StatementResult
@@ -1018,13 +994,8 @@ class Frame<R> {
}
const init = declaration.init
// `var x` alone is a no-op: the binding was hoisted on function entry.
if (kind === "var") {
if (init) yield* self.assignPattern(declaration.id, yield* self.evaluateExpression(init), declaration)
continue
}
const value = init ? yield* self.evaluateExpression(init) : undefined
yield* self.declarePattern(declaration.id, value, kind !== "const", declaration, true)
yield* self.declarePattern(declaration.id, value, kind !== "const", declaration, kind !== "var")
}
})
}
@@ -1054,7 +1025,7 @@ class Frame<R> {
if (pattern.type === "ObjectPattern") {
if (value === null || typeof value !== "object" || isRuntimeReference(value)) {
throw new InterpreterRuntimeError(
`Object destructuring requires a data object or array value, received ${describeValue(value)}.`,
"Object destructuring requires a data object or array value.",
pattern,
"InvalidDataValue",
)
@@ -1065,7 +1036,7 @@ class Frame<R> {
if (property.type === "RestElement") {
const rest: SafeObject = Object.create(null) as SafeObject
for (const [key, item] of Object.entries(value as SafeObject)) {
if (!consumed.has(key)) rest[key] = item
if (!consumed.has(key) && !isBlockedMember(key)) rest[key] = item
}
copyIteratorSymbols(value, rest, consumed)
yield* self.declarePattern(property.argument, rest, mutable, property, initialize)
@@ -1073,6 +1044,9 @@ class Frame<R> {
}
const key = yield* self.destructuringPropertyKey(property)
if (isBlockedMember(String(key))) {
throw new InterpreterRuntimeError(`Property '${String(key)}' is not available.`, property)
}
consumed.add(typeof key === "symbol" ? key : String(key))
yield* self.declarePattern(
property.value,
@@ -1117,7 +1091,7 @@ class Frame<R> {
if (pattern.type === "ObjectPattern") {
if (value === null || typeof value !== "object" || isRuntimeReference(value)) {
throw new InterpreterRuntimeError(
`Object destructuring requires a data object or array value, received ${describeValue(value)}.`,
"Object destructuring requires a data object or array value.",
pattern,
"InvalidDataValue",
)
@@ -1129,13 +1103,16 @@ class Frame<R> {
if (property.type === "RestElement") {
const rest: SafeObject = Object.create(null) as SafeObject
for (const [key, item] of Object.entries(source)) {
if (!consumed.has(key)) rest[key] = item
if (!consumed.has(key) && !isBlockedMember(key)) rest[key] = item
}
copyIteratorSymbols(source, rest, consumed)
yield* self.assignPattern(property.argument, rest, property)
continue
}
const key = yield* self.destructuringPropertyKey(property)
if (isBlockedMember(String(key))) {
throw new InterpreterRuntimeError(`Property '${String(key)}' is not available.`, property)
}
consumed.add(typeof key === "symbol" ? key : String(key))
yield* self.assignPattern(property.value, self.destructuringPropertyValue(source, key), property)
}
@@ -1287,19 +1264,7 @@ class Frame<R> {
const callee = yield* self.evaluateExpression(node.callee)
// Globals are built with this interpreter's R; `instanceof` cannot recover the type argument.
const construct = callee instanceof HostFunction ? (callee as HostFunction<R>).construct : undefined
if (construct === undefined) {
// `new` itself is supported, so a non-constructible callee is a TypeError like JS rather than
// unsupported syntax. Built-ins like Number are real constructors in JS, so do not claim
// otherwise; say `new` is unsupported for them and point at the plain call.
const name = calleeDescription(node.callee)
const message =
callee instanceof CodeModeFunction
? `${name} cannot be constructed: user-defined constructors and classes are not supported. Call it as a function that returns a plain object instead.`
: callee instanceof HostFunction
? `new ${name}(...) is not supported; call ${name}(...) without new instead.`
: `${name} is not a constructor.`
throw new InterpreterRuntimeError(message, node).as("TypeError")
}
if (construct === undefined) throw unsupportedSyntax("NewExpression", node)
const args = yield* self.evaluateCallArguments(node.arguments)
return yield* construct(args, node)
})
@@ -1645,8 +1610,6 @@ class Frame<R> {
}
if (fn.body.type === "BlockStatement") {
invocation.scopes.push()
invocation.hoistVars(fn.body.body, paramScope)
const result = yield* invocation.evaluateStatement(fn.body)
return result.kind === "return" ? result.value : undefined
}
@@ -1655,8 +1618,16 @@ class Frame<R> {
})
if (fn.generator) return Effect.succeed(this.createGenerator(invocation, run, fn.async))
if (!fn.async) return run
return this.runtime.promises.createWithSelf((self) =>
Effect.flatMap(run, (value) => resolvePromiseValue(invocation.runtime.runner, value, fn.body, self)),
// The initial yield assigns the promise before the body can self-resolve.
const box: { promise?: Values.Promise } = {}
return Effect.map(
this.createPromise(
Effect.flatMap(run, (value) => resolvePromiseValue(invocation.runtime.runner, value, fn.body, box)),
),
(promise) => {
box.promise = promise
return promise
},
)
}
@@ -1911,10 +1882,15 @@ class Frame<R> {
for (const property of node.properties) {
if (property.type === "SpreadElement") {
const spread = yield* self.evaluateExpression(property.argument)
if (spread === null || spread === undefined) continue
const from = enumerableSource("Object spread", spread, property)
for (const [key, value] of Object.entries(from)) objectValue[key] = value
if (typeof from === "object") copyIteratorSymbols(from, objectValue)
if (spread === null || spread === undefined || Values.isValue(spread)) continue
if (typeof spread !== "object" || Array.isArray(spread) || isRuntimeReference(spread)) {
throw new InterpreterRuntimeError("Object spread requires a data object.", property, "InvalidDataValue")
}
for (const [key, value] of Object.entries(spread)) {
if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available.`, property)
objectValue[key] = value
}
copyIteratorSymbols(spread, objectValue)
continue
}
@@ -1936,6 +1912,9 @@ class Frame<R> {
throw new InterpreterRuntimeError("Unsupported object property key shape.", keyNode)
}
if (isBlockedMember(String(key))) {
throw new InterpreterRuntimeError(`Property '${String(key)}' is not available.`, keyNode)
}
Reflect.set(objectValue, key, yield* self.evaluateExpression(property.value))
}
@@ -2013,7 +1992,7 @@ class Frame<R> {
private getMemberReference(
node: MemberExpression,
operation: "read" | "write" | "delete" = "read",
operation: "read" | "delete" = "read",
): Effect.Effect<
| MemberReference
| ToolReference
@@ -2050,16 +2029,13 @@ class Frame<R> {
}
if (objectValue instanceof HostFunction || objectValue instanceof HostNamespace) {
if (typeof key === "string" && isBlockedMember(key)) {
throw new InterpreterRuntimeError(`${objectValue.name}.${key} is not available.`, propertyNode)
}
// Unknown static members read as undefined so feature detection works like native JS.
return new ComputedValue(objectValue.member(key, propertyNode))
}
// Values have no prototype chain, so `.constructor` resolves to the owning built-in directly.
if (operation === "read" && key === "constructor" && !hasOwn(objectValue, key)) {
const name = constructorName(objectValue)
if (name !== undefined) return new ComputedValue(self.runtime.builtins.get(name))
}
if (typeof objectValue === "string") {
if (key === "length") return new ComputedValue(objectValue.length)
const index = typeof key === "symbol" ? undefined : parseArrayIndex(key)
@@ -2138,7 +2114,7 @@ class Frame<R> {
if (isRuntimeReference(objectValue)) {
throw new InterpreterRuntimeError(
`Cannot read properties of ${describeValue(objectValue)}; only data values expose properties.`,
"Runtime references are opaque and do not expose properties.",
objectNode,
"InvalidDataValue",
)
@@ -2148,6 +2124,10 @@ class Frame<R> {
throw new InterpreterRuntimeError("Cannot access a property on a non-object value.", objectNode)
}
if (typeof key === "string" && isBlockedMember(key)) {
throw new InterpreterRuntimeError(`Property '${key}' is not available.`, propertyNode)
}
if (Array.isArray(objectValue)) {
if (operation === "delete") return { target: objectValue, key }
const index = typeof key === "symbol" ? undefined : parseArrayIndex(key)
@@ -2215,7 +2195,7 @@ class Frame<R> {
): Effect.Effect<unknown, unknown, R> {
const self = this
return Effect.gen(function* () {
const reference = yield* self.getMemberReference(node, "write")
const reference = yield* self.getMemberReference(node)
if (
reference === OptionalShortCircuit ||
reference instanceof ComputedValue ||
+5 -3
View File
@@ -1,5 +1,6 @@
import { fromSchemaOpenApi3_0, fromSchemaOpenApi3_1 } from "effect/JsonSchema"
import type { JsonSchema } from "../tool.js"
import { isBlockedMember } from "../data.js"
import type {
Body,
Document,
@@ -467,7 +468,8 @@ export const operationInput = (
ok: true,
value: {
fields: fields.map((field) => {
const base = conflicts.has(field.name) ? `${field.location}_${field.name}` : field.name
const visibleName = isBlockedMember(field.name) ? `${field.name}_2` : field.name
const base = conflicts.has(field.name) ? `${field.location}_${visibleName}` : visibleName
const next = (index: number): string => {
const candidate = index === 1 ? base : `${base}_${index}`
return used.has(candidate) ? next(index + 1) : candidate
@@ -565,12 +567,12 @@ export const operationOutput = (
}
const sanitizeOperationSegment = (raw: string): string => {
return (
const base =
raw
.replaceAll(/[^A-Za-z0-9_$]+/g, "_")
.replace(/^_+|_+$/g, "")
.replace(/^([0-9])/, "_$1") || "operation"
)
return isBlockedMember(base) ? `${base}_2` : base
}
const fallbackOperationId = (method: string, path: string): string =>
+9 -2
View File
@@ -1,8 +1,8 @@
import { Effect } from "effect"
import { HostFunction, sync, syncCall } from "../interpreter/host.js"
import { type AstNode, CodeModeGenerator, InterpreterRuntimeError } from "../interpreter/model.js"
import { describeValue } from "../interpreter/references.js"
import { applyCollectionCallback, preserveConsumerError, type Runner } from "../interpreter/runner.js"
import { Values } from "../values.js"
const constructArray = (args: Array<unknown>, node: AstNode): Array<unknown> => {
if (args.length !== 1) return [...args]
@@ -16,6 +16,13 @@ const constructArray = (args: Array<unknown>, node: AstNode): Array<unknown> =>
}
const arrayLikeSource = (source: unknown, node: AstNode): { readonly length: number; readonly source: object } => {
if (source instanceof Values.Promise) {
throw new InterpreterRuntimeError(
"Array.from received an un-awaited Promise; await it before creating the array.",
node,
"InvalidDataValue",
)
}
if (
source !== null &&
typeof source === "object" &&
@@ -28,7 +35,7 @@ const arrayLikeSource = (source: unknown, node: AstNode): { readonly length: num
return { length: normalized, source }
}
throw new InterpreterRuntimeError(
`Array.from expects an array, string, Map, Set, or array-like value, received ${describeValue(source)}.`,
"Array.from expects an array, string, Map, Set, or array-like value.",
node,
"InvalidDataValue",
)
+9 -7
View File
@@ -1,8 +1,8 @@
import { Effect } from "effect"
import type { SafeObject } from "../data.js"
import { isBlockedMember, type SafeObject } from "../data.js"
import { HostFunction, requiresNew } from "../interpreter/host.js"
import { type AstNode, InterpreterRuntimeError, isRecord } from "../interpreter/model.js"
import { describeValue, isRuntimeReference } from "../interpreter/references.js"
import { isRuntimeReference } from "../interpreter/references.js"
import { applyCollectionCallback, preserveConsumerError, type Runner, toPrimitive } from "../interpreter/runner.js"
import { Values } from "../values.js"
import { coerceToString } from "./value.js"
@@ -73,11 +73,7 @@ const coerceGroupByPropertyKey = <R>(
): Effect.Effect<string, unknown, R> => {
if (value instanceof Values.Promise) return Effect.succeed("[object Promise]")
if (!Values.isValue(value) && isRuntimeReference(value)) {
throw new InterpreterRuntimeError(
`Object.groupBy callback must return a data value, received ${describeValue(value)}.`,
node,
"InvalidDataValue",
)
throw new InterpreterRuntimeError("Object.groupBy callback must return a data value.", node, "InvalidDataValue")
}
return Effect.map(toPrimitive(runner, value, "string", node), coerceToString)
}
@@ -124,6 +120,12 @@ export const groupBy = <R>(runner: Runner<R>, namespace: "Map" | "Object") =>
cursor,
Effect.flatMap(apply([item, index]), (value) => coerceGroupByPropertyKey(runner, value, node)),
)
if (isBlockedMember(key)) {
return yield* preserveConsumerError(
cursor,
Effect.fail(new InterpreterRuntimeError(`Property '${key}' is not available.`, node)),
)
}
const group = result[key]
if (group === undefined) result[key] = [item]
else (group as Array<unknown>).push(item)
-6
View File
@@ -29,8 +29,6 @@ export const dateMethods = new Set([
"toISOString",
"toJSON",
"toString",
"toDateString",
"toTimeString",
"toUTCString",
"toGMTString",
"getFullYear",
@@ -105,10 +103,6 @@ export const invokeDateMethod = (
return Number.isFinite(value.time) ? hosted.toISOString() : null
case "toString":
return coerceToString(value)
case "toDateString":
return hosted.toDateString()
case "toTimeString":
return hosted.toTimeString()
case "toUTCString":
case "toGMTString":
return hosted.toUTCString()
+9
View File
@@ -6,6 +6,15 @@ import { typeofValue } from "../interpreter/references.js"
import { fromData, type SafeObject, toData, toProgram } from "../data.js"
import { Values } from "../values.js"
export const invokeJsonMethod = <R>(
runner: Runner<R>,
name: "parse" | "stringify",
args: Array<unknown>,
node: AstNode,
): Effect.Effect<unknown, unknown, R> => {
return name === "parse" ? parse(runner, args, node) : stringify(runner, args, node)
}
export const jsonGlobal = <R>(runner: Runner<R>) =>
new HostNamespace("JSON", {
parse: new HostFunction<R>({ name: "JSON.parse", call: (args, node) => parse(runner, args, node) }),
+60 -62
View File
@@ -1,68 +1,44 @@
import { Effect } from "effect"
import { toProgram } from "../data.js"
import { isBlockedMember, toProgram } from "../data.js"
import { HostFunction, sync, syncCall } from "../interpreter/host.js"
import { type AstNode, AsyncIteratorSymbol, InterpreterRuntimeError, IteratorSymbol } from "../interpreter/model.js"
import {
containsOpaqueReference,
describeValue,
isRuntimeReference,
parseArrayIndex,
rejectCircularInsertion,
typeofValue,
} from "../interpreter/references.js"
import { containsOpaqueReference, rejectCircularInsertion, typeofValue } from "../interpreter/references.js"
import { preserveConsumerError, type Runner } from "../interpreter/runner.js"
import { ToolReference } from "../tool-runtime.js"
import { Values } from "../values.js"
import { groupBy } from "./collections.js"
import { coerceToString } from "./value.js"
// ToObject for enumeration. Strings return themselves: the host's Object.keys/entries/hasOwn index a
// primitive string directly. Numbers, booleans, wrappers, and functions have no own enumerable keys.
export const enumerableSource = (label: string, value: unknown, node: AstNode): Record<string, unknown> => {
if (value === null || value === undefined) {
throw new InterpreterRuntimeError(`${label} cannot convert ${describeValue(value)} to an object.`, node).as(
"TypeError",
)
}
if (value instanceof Values.Promise) {
const requireObject = (name: string, input: unknown, node: AstNode): Record<string, unknown> => {
if (Array.isArray(input)) return input as unknown as Record<string, unknown>
if (Values.isValue(input)) return {}
if (input instanceof Values.Promise) {
throw new InterpreterRuntimeError(
`${label} received an un-awaited Promise; await it before inspecting the result.`,
`Object.${name} received an un-awaited Promise; await it before inspecting the result.`,
node,
"InvalidDataValue",
)
}
if (value instanceof ToolReference) {
throw new InterpreterRuntimeError(
`${label} cannot read tool references: they are not plain data. Use Object.keys(tools) for names, or search({ query }) for signatures.`,
node,
"InvalidDataValue",
)
if (input === null || typeof input !== "object") {
throw new InterpreterRuntimeError(`Object.${name} expects a data object or array.`, node, "InvalidDataValue")
}
if (typeof value === "string") return value as unknown as Record<string, unknown>
if (typeof value !== "object" || Values.isValue(value) || isRuntimeReference(value)) return {}
return value as Record<string, unknown>
const prototype = Object.getPrototypeOf(input)
if (prototype !== null && prototype !== Object.prototype) {
throw new InterpreterRuntimeError(`Object.${name} expects a data object or array.`, node, "InvalidDataValue")
}
return input as Record<string, unknown>
}
export const objectAssign = (args: Array<unknown>, node: AstNode): unknown => {
const target = args[0]
// JS would box a primitive target; wrappers and primitives cannot hold fields here.
if (target === null || typeof target !== "object" || Values.isValue(target) || isRuntimeReference(target)) {
throw new InterpreterRuntimeError(
`Object.assign expects a data object or array target, received ${describeValue(target)}.`,
node,
).as("TypeError")
if (target === null || typeof target !== "object" || Array.isArray(target) || Values.isValue(target)) {
throw new InterpreterRuntimeError("Object.assign expects a data object target.", node)
}
const out = target as Record<string, unknown>
const seen = new Set<object>()
const guardedSet = (key: PropertyKey, item: unknown): void => {
// Arrays hold only indexed elements, as with direct assignment; Reflect.set would otherwise
// reach Array's length and Object.prototype's __proto__ setter.
if (Array.isArray(out) && (typeof key === "symbol" || parseArrayIndex(key) === undefined)) {
throw new InterpreterRuntimeError(
`Object.assign cannot assign '${String(key)}' to an array: only array indexes may be assigned.`,
node,
).as("TypeError")
}
if (typeof key === "string" && isBlockedMember(key))
throw new InterpreterRuntimeError(`Property '${key}' is not available.`, node)
rejectCircularInsertion(out, item, "Object.assign result", node, seen)
if (!Reflect.set(out, key, item))
throw new InterpreterRuntimeError(`Object.assign could not assign property '${String(key)}'.`, node).as(
@@ -70,15 +46,18 @@ export const objectAssign = (args: Array<unknown>, node: AstNode): unknown => {
)
}
for (const source of args.slice(1)) {
if (source === null || source === undefined) continue
const from = enumerableSource("Object.assign(...)", source, node)
if (typeof from !== "object") {
for (const [key, item] of Object.entries(from)) guardedSet(key, item)
continue
if (source === null || source === undefined || Values.isValue(source)) continue
if (typeof source !== "object" || Array.isArray(source)) {
throw new InterpreterRuntimeError("Object.assign expects data objects.", node)
}
for (const key of Reflect.ownKeys(from)) {
if (typeof key === "symbol" && key !== AsyncIteratorSymbol && key !== IteratorSymbol) continue
if (Object.prototype.propertyIsEnumerable.call(from, key)) guardedSet(key, Reflect.get(from, key))
for (const key of Reflect.ownKeys(source)) {
if (typeof key === "string") {
if (Object.prototype.propertyIsEnumerable.call(source, key)) guardedSet(key, Reflect.get(source, key))
continue
}
if (key !== AsyncIteratorSymbol && key !== IteratorSymbol) continue
if (!Object.prototype.propertyIsEnumerable.call(source, key)) continue
guardedSet(key, Reflect.get(source, key))
}
}
return out
@@ -117,6 +96,7 @@ const objectFromEntries = <R>(
toProgram(entry[0], "Object.fromEntries key")
toProgram(entry[1], "Object.fromEntries value")
const key = coerceToString(entry[0])
if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available.`, node)
out[key] = entry[1]
}),
)
@@ -126,7 +106,7 @@ const objectFromEntries = <R>(
const constructObject = (args: Array<unknown>, node: AstNode): unknown => {
const first = args[0]
if (first === null || first === undefined) return Object.create(null)
if (first === null || first === undefined) return {}
if (typeof first === "object") return first
throw new InterpreterRuntimeError(
`Object(${typeof first}) wrapper objects are not supported; use the primitive value directly.`,
@@ -134,6 +114,22 @@ const constructObject = (args: Array<unknown>, node: AstNode): unknown => {
)
}
// Tool references are not data; only Object.keys(tools) reads them, for tool names.
const rejectTools = (name: string, args: Array<unknown>, node: AstNode): void => {
if (!(args[0] instanceof ToolReference)) return
throw new InterpreterRuntimeError(
`Object.${name}(...) cannot read tool references: they are not plain data. Use Object.keys(tools) for names, or search({ query }) for signatures.`,
node,
"InvalidDataValue",
)
}
const objectStatic = (name: string, impl: (args: Array<unknown>, node: AstNode) => unknown) =>
sync(`Object.${name}`, (args, node) => {
rejectTools(name, args, node)
return impl(args, node)
})
// Object constructs identically with or without new, like JS. Only `keys` copies its result into the
// program; `values`, `entries`, `assign`, and `fromEntries` hand back the program's own values.
export const objectGlobal = <R>(runner: Runner<R>, toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>) =>
@@ -147,32 +143,34 @@ export const objectGlobal = <R>(runner: Runner<R>, toolKeys: (path: ReadonlyArra
toProgram(
args[0] instanceof ToolReference
? [...toolKeys(args[0].path)]
: Object.keys(enumerableSource("Object.keys(...)", args[0], node)),
: Object.keys(requireObject("keys", args[0], node)),
"Object.keys result",
),
),
values: sync("Object.values", (args, node) =>
Object.values(enumerableSource("Object.values(...)", args[0], node)),
values: objectStatic("values", (args, node) => Object.values(requireObject("values", args[0], node))),
entries: objectStatic("entries", (args, node) =>
Object.entries(requireObject("entries", args[0], node)).map(([key, item]) => [key, item]),
),
entries: sync("Object.entries", (args, node) =>
Object.entries(enumerableSource("Object.entries(...)", args[0], node)).map(([key, item]) => [key, item]),
),
hasOwn: sync("Object.hasOwn", (args, node) =>
hasOwn: objectStatic("hasOwn", (args, node) =>
Object.hasOwn(
enumerableSource("Object.hasOwn(...)", args[0], node),
requireObject("hasOwn", args[0], node),
args[1] === AsyncIteratorSymbol || args[1] === IteratorSymbol ? args[1] : String(args[1]),
),
),
is: sync("Object.is", (args, node) => {
is: objectStatic("is", (args, node) => {
if (containsOpaqueReference(args[0]) || containsOpaqueReference(args[1])) {
throw new InterpreterRuntimeError("Object.is requires data values.", node, "InvalidDataValue")
}
return Object.is(args[0], args[1])
}),
assign: sync("Object.assign", objectAssign),
assign: objectStatic("assign", objectAssign),
fromEntries: new HostFunction<R>({
name: "Object.fromEntries",
call: (args, node) => Effect.suspend(() => objectFromEntries(runner, args[0], node)),
call: (args, node) =>
Effect.suspend(() => {
rejectTools("fromEntries", args, node)
return objectFromEntries(runner, args[0], node)
}),
}),
groupBy: groupBy(runner, "Object"),
},
+3 -3
View File
@@ -1,6 +1,6 @@
import { sync, syncCall } from "../interpreter/host.js"
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import type { SafeObject } from "../data.js"
import { isBlockedMember, type SafeObject } from "../data.js"
import { Values } from "../values.js"
import { coerceToNumber, coerceToString } from "./value.js"
@@ -62,7 +62,7 @@ export const matchToValue = (match: RegExpMatchArray): Array<unknown> => {
if (match.groups) {
const groups: SafeObject = Object.create(null) as SafeObject
for (const [key, group] of Object.entries(match.groups)) {
groups[key] = group
if (!isBlockedMember(key)) groups[key] = group
}
result.groups = groups
}
@@ -148,7 +148,7 @@ const indicesToValue = (indices: RegExpIndicesArray): IndicesValue => {
if (indices.groups) {
const groups: SafeObject = Object.create(null) as SafeObject
for (const [key, range] of Object.entries(indices.groups)) {
groups[key] = range === undefined ? undefined : [...range]
if (!isBlockedMember(key)) groups[key] = range === undefined ? undefined : [...range]
}
result.groups = groups
return result
-5
View File
@@ -8,12 +8,9 @@ export const stringMethods = new Set([
"trim",
"trimStart",
"trimEnd",
"trimLeft",
"trimRight",
"split",
"slice",
"substring",
"substr",
"includes",
"startsWith",
"endsWith",
@@ -35,8 +32,6 @@ export const stringMethods = new Set([
"search",
"localeCompare",
"normalize",
"isWellFormed",
"toWellFormed",
])
const codeUnits = (name: string, op: (...codes: Array<number>) => string) =>
-80
View File
@@ -1,80 +0,0 @@
import { define, type SafeObject } from "../data.js"
import { HostNamespace, sync } from "../interpreter/host.js"
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import { describeValue, isRuntimeReference } from "../interpreter/references.js"
import { Values } from "../values.js"
import { coerceToString, createErrorValue, errorBrandName } from "./value.js"
// WebIDL DOMString conversion: a missing argument is a TypeError, anything else stringifies.
const base64 = (name: "atob" | "btoa") =>
sync(name, (args, node) => {
if (args.length === 0) {
throw new InterpreterRuntimeError(`${name} requires 1 argument, but only 0 were provided.`, node).as("TypeError")
}
const input = coerceToString(args[0])
try {
return name === "atob" ? atob(input) : btoa(input)
} catch {
throw new InterpreterRuntimeError("The string contains invalid characters.", node).as("InvalidCharacterError")
}
})
export const atobGlobal = base64("atob")
export const btoaGlobal = base64("btoa")
export const cryptoGlobal = new HostNamespace("crypto", {
randomUUID: sync("crypto.randomUUID", () => crypto.randomUUID()),
})
// HTML structured clone over the data model: wrappers are copied, shared references stay shared within
// one clone, Errors keep only name, message, and cause, and RegExp lastIndex resets like the spec.
const cloneValue = (value: unknown, seen: Map<object, unknown>, node: AstNode): unknown => {
if (value === null || typeof value !== "object") return value
if (value instanceof Values.Promise || (isRuntimeReference(value) && !Values.isValue(value))) {
throw new InterpreterRuntimeError(`${describeValue(value)} could not be cloned.`, node).as("DataCloneError")
}
const existing = seen.get(value)
if (existing !== undefined) return existing
const remember = <T extends object>(copied: T): T => {
seen.set(value, copied)
return copied
}
if (value instanceof Values.Date) return remember(new Values.Date(value.time))
if (value instanceof Values.RegExp) return remember(new Values.RegExp(value.regex.source, value.regex.flags))
if (value instanceof Values.URL) return remember(new Values.URL(new URL(value.url.href)))
if (value instanceof Values.URLSearchParams) {
return remember(new Values.URLSearchParams(new URLSearchParams(value.params)))
}
if (value instanceof Values.Map) {
const copied = remember(new Values.Map())
for (const [key, item] of value.map) copied.map.set(cloneValue(key, seen, node), cloneValue(item, seen, node))
return copied
}
if (value instanceof Values.Set) {
const copied = remember(new Values.Set())
for (const item of value.set) copied.set.add(cloneValue(item, seen, node))
return copied
}
if (Array.isArray(value)) {
const copied = remember(new Array<unknown>(value.length))
for (const [key, item] of Object.entries(value)) define(copied, key, cloneValue(item, seen, node))
return copied
}
const brand = errorBrandName(value)
if (brand !== undefined) {
const error = value as { name?: unknown; message?: unknown; cause?: unknown }
const copied = remember(createErrorValue(brand, coerceToString(error.message)))
if (Object.hasOwn(value, "cause")) copied.cause = cloneValue(error.cause, seen, node)
return copied
}
const copied = remember(Object.create(null) as SafeObject)
for (const [key, item] of Object.entries(value)) define(copied, key, cloneValue(item, seen, node))
return copied
}
export const structuredCloneGlobal = sync("structuredClone", (args, node) => {
if (args.length === 0) {
throw new InterpreterRuntimeError("structuredClone requires a value to clone.", node).as("TypeError")
}
return cloneValue(args[0], new Map(), node)
})
-11
View File
@@ -1,11 +0,0 @@
# The 3-Clause BSD License
Copyright © web-platform-tests contributors
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+7 -7
View File
@@ -528,7 +528,7 @@ describe("CodeMode schema flexibility", () => {
})
const runtime = CodeMode.make({ tools: { adapter: { call } } })
expect(runtime.catalog).toStrictEqual([
expect(runtime.catalog()).toStrictEqual([
{
path: "adapter.call",
description: "Call an adapter-described tool",
@@ -611,7 +611,7 @@ describe("CodeMode schema flexibility", () => {
})
const runtime = CodeMode.make({ tools: { users: { lookup } } })
expect(runtime.catalog).toStrictEqual([
expect(runtime.catalog()).toStrictEqual([
{
path: "users.lookup",
description: "Look up a user",
@@ -631,7 +631,7 @@ describe("CodeMode schema flexibility", () => {
execute: () => Effect.succeed("pong"),
})
const runtime = CodeMode.make({ tools: { net: { ping } } })
expect(runtime.catalog[0]?.signature).toBe("tools.net.ping(input: {\n host: string,\n}): Promise<void>")
expect(runtime.catalog()[0]?.signature).toBe("tools.net.ping(input: {\n host: string,\n}): Promise<void>")
const result = await Effect.runPromise(runtime.execute(`return await tools.net.ping({ host: "example.test" })`))
expect(result.ok).toBe(true)
@@ -684,7 +684,7 @@ describe("CodeMode public contract", () => {
test("describes the catalog and keeps the search built-in registered", async () => {
const runtime = CodeMode.make({ tools })
expect(runtime.catalog).toStrictEqual([
expect(runtime.catalog()).toStrictEqual([
{
path: "orders.lookup",
description: "Look up an order by ID",
@@ -726,8 +726,8 @@ describe("CodeMode public contract", () => {
const first = CodeMode.make({ tools: { zeta: { zeta, alpha }, alpha: { zeta, alpha } } })
const second = CodeMode.make({ tools: { alpha: { alpha, zeta }, zeta: { alpha, zeta } } })
expect(first.catalog).toStrictEqual(second.catalog)
expect(first.catalog.map((tool) => tool.path)).toEqual(["alpha.alpha", "alpha.zeta", "zeta.alpha", "zeta.zeta"])
expect(first.catalog()).toStrictEqual(second.catalog())
expect(first.catalog().map((tool) => tool.path)).toEqual(["alpha.alpha", "alpha.zeta", "zeta.alpha", "zeta.zeta"])
})
test("renders bracket notation for tool names that are not JavaScript identifiers", async () => {
@@ -739,7 +739,7 @@ describe("CodeMode public contract", () => {
})
const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } })
expect(runtime.catalog).toStrictEqual([
expect(runtime.catalog()).toStrictEqual([
{
path: "context7.resolve-library-id",
description: "Resolve a library ID",
@@ -1,66 +0,0 @@
/**
* Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75:
* - test/built-ins/RegExp/S15.10.7_A3_T1.js
* - test/built-ins/RegExp/S15.10.7_A3_T2.js
* - test/built-ins/Object/S15.2.2.1_A1_T1.js
*
* Copyright 2009 the Sputnik authors. All rights reserved.
* Test262 portions are governed by the BSD license in LICENSE.test262.
*
* Only the instance-side assertions are ported. Test262 otherwise reaches `constructor` through
* `X.prototype.constructor`, boxed primitives (`new Object(1)`), `Function`, `isPrototypeOf`, or
* `.call`, none of which CodeMode exposes: values have no prototype chain, so `x.constructor`
* resolves directly to the owning built-in.
*/
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { CodeMode } from "../src/index.js"
const value = async (code: string) => {
const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} }))
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
return result.value
}
describe("constructor Test262 parity", () => {
test("test/built-ins/RegExp/S15.10.7_A3_T1.js", async () => {
expect(
await value(`
const __re = /[^a]*/
return [typeof __re, __re.constructor === RegExp, __re instanceof RegExp]
`),
).toEqual(["object", true, true])
})
test("test/built-ins/RegExp/S15.10.7_A3_T2.js", async () => {
expect(
await value(`
const __re = new RegExp()
return [typeof __re, __re.constructor === RegExp, __re instanceof RegExp]
`),
).toEqual(["object", true, true])
})
test("test/built-ins/Object/S15.2.2.1_A1_T1.js", async () => {
expect(
await value(`
const obj = new Object()
return [obj !== undefined, obj.constructor === Object]
`),
).toEqual([true, true])
})
test("every built-in reports itself for its own values", async () => {
expect(
await value(`
return [
[].constructor === Array, "".constructor === String, (1).constructor === Number, true.constructor === Boolean,
new Date(0).constructor === Date, new Map().constructor === Map, new Set().constructor === Set,
new URL("https://a.b/").constructor === URL, new URLSearchParams("a=1").constructor === URLSearchParams,
Promise.resolve(1).constructor === Promise, new TypeError("x").constructor === TypeError,
new RangeError("x").constructor === RangeError, new AggregateError([]).constructor === AggregateError,
]
`),
).toEqual(Array(13).fill(true))
})
})
+9 -30
View File
@@ -85,20 +85,9 @@ describe("Object.keys over arrays", () => {
expect(await value(`return Object.keys({ a: 1, b: 2 })`)).toEqual(["a", "b"])
})
test("non-object inputs follow ToObject, and nullish inputs name what was received", async () => {
expect(
await value(`return [Object.keys("ab"), Object.entries(42), Object.keys(() => 1), Object.keys(true)]`),
).toEqual([["0", "1"], [], [], []])
expect(await value(`try { Object.values(null) } catch (e) { return [e.name, e.message] }`)).toEqual([
"TypeError",
"Object.values(...) cannot convert null to an object.",
])
expect((await error(`return Object.keys(tools.github.list_issues({ value: "x" }))`)).message).toContain(
"received an un-awaited Promise",
)
expect((await error(`const { a } = new Map(); return a`)).message).toContain("received a Map.")
expect((await error(`return Array.from(7)`)).message).toContain("received a number.")
expect((await error(`return (() => 1).x`)).message).toContain("Cannot read properties of a function")
test("non-object inputs still fail clearly", async () => {
const failure = await error(`return Object.keys("nope")`)
expect(failure.message).toContain("Object.keys expects a data object or array")
})
})
@@ -159,21 +148,11 @@ describe("for...in", () => {
).toEqual(["github.list_issues", "github.get_issue", "memory.search", "playwright.navigate"])
})
test("non-object values enumerate like JS: strings by index, everything else nothing", async () => {
expect(
await value(`
const out = []
for (const key in "ab") out.push(key)
for (const key in 42) out.push(key)
for (const key in null) out.push(key)
for (const key in undefined) out.push(key)
for (const key in new Map([[1, 2]])) out.push(key)
for (const key in Math) out.push(key)
return out
`),
).toEqual(["0", "1"])
expect((await error(`for (const key in tools.github.list_issues({ value: "x" })) {}`)).message).toContain(
"un-awaited Promise",
)
test("unsupported values fail with a hint at for...of and Object.keys", async () => {
for (const expression of [`"text"`, "new Map([[1, 2]])", "new Set([1])", "42", "null"]) {
const failure = await error(`for (const key in ${expression}) {}; return "no"`)
expect(failure.message).toContain("for...in requires a plain object, array, or tools reference")
expect(failure.message).toContain("Use for...of for arrays/strings/Maps/Sets, or Object.keys(value)")
}
})
})
-82
View File
@@ -1,82 +0,0 @@
[
["", []],
["abcd", [105, 183, 29]],
[" abcd", [105, 183, 29]],
["abcd ", [105, 183, 29]],
[" abcd===", null],
["abcd=== ", null],
["abcd ===", null],
["a", null],
["ab", [105]],
["abc", [105, 183]],
["abcde", null],
["𐀀", null],
["=", null],
["==", null],
["===", null],
["====", null],
["=====", null],
["a=", null],
["a==", null],
["a===", null],
["a====", null],
["a=====", null],
["ab=", null],
["ab==", [105]],
["ab===", null],
["ab====", null],
["ab=====", null],
["abc=", [105, 183]],
["abc==", null],
["abc===", null],
["abc====", null],
["abc=====", null],
["abcd=", null],
["abcd==", null],
["abcd===", null],
["abcd====", null],
["abcd=====", null],
["abcde=", null],
["abcde==", null],
["abcde===", null],
["abcde====", null],
["abcde=====", null],
["=a", null],
["=a=", null],
["a=b", null],
["a=b=", null],
["ab=c", null],
["ab=c=", null],
["abc=d", null],
["abc=d=", null],
["ab\u000Bcd", null],
["ab\u3000cd", null],
["ab\u3001cd", null],
["ab\tcd", [105, 183, 29]],
["ab\ncd", [105, 183, 29]],
["ab\fcd", [105, 183, 29]],
["ab\rcd", [105, 183, 29]],
["ab cd", [105, 183, 29]],
["ab\u00a0cd", null],
["ab\t\n\f\r cd", [105, 183, 29]],
[" \t\n\f\r ab\t\n\f\r cd\t\n\f\r ", [105, 183, 29]],
["ab\t\n\f\r =\t\n\f\r =\t\n\f\r ", [105]],
["A", null],
["/A", [252]],
["//A", [255, 240]],
["///A", [255, 255, 192]],
["////A", null],
["/", null],
["A/", [3]],
["AA/", [0, 15]],
["AAAA/", null],
["AAA/", [0, 0, 63]],
["\u0000nonsense", null],
["abcd\u0000nonsense", null],
["YQ", [97]],
["YR", [97]],
["~~", null],
["..", null],
["--", null],
["__", null]
]
@@ -29,6 +29,7 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { CodeMode } from "../src/index.js"
import { invokeJsonMethod } from "../src/stdlib/json.js"
const value = async (code: string) => {
const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} }))
@@ -217,19 +218,27 @@ describe("CodeMode JSON callback boundaries", () => {
expect(result).toMatchObject({ ok: false, error: { kind: "UnsupportedSyntax" } })
})
test("prototype-named keys parse as own data and reach the reviver", async () => {
test("blocked parse keys are rejected before reviver traversal", async () => {
expect(
await value(`
const seen = []
const parsed = JSON.parse('{"__proto__":{"polluted":1},"constructor":2}', (key, item) => { seen.push(key); return item })
return [seen, parsed.__proto__.polluted, parsed.constructor, ({}).polluted, Object.keys(parsed)]
`),
).toEqual([["polluted", "__proto__", "constructor", ""], 1, 2, null, ["__proto__", "constructor"]])
await value(
`try { JSON.parse('{"__proto__":1}', (key, item) => item) } catch (error) { return true } return false`,
),
).toBe(true)
})
test("JSON.stringify serializes prototype-named own keys", async () => {
expect(await value(`return JSON.stringify({ constructor: 1, __proto__: 2 })`)).toBe(
'{"constructor":1,"__proto__":2}',
)
test("JSON.stringify directly rejects blocked input keys", () => {
expect(() =>
invokeJsonMethod(
{
invokeFunction: () => Effect.die("unused"),
invokeCallable: () => Effect.die("unused"),
settlePromise: () => Effect.die("unused"),
syncIterator: () => Effect.die("unused"),
},
"stringify",
[Object.fromEntries([["constructor", 1]])],
{ type: "CallExpression", start: 0, end: 0 },
),
).toThrow("blocked property 'constructor'")
})
})
@@ -1,80 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { CodeMode, Tool } from "../src/index.js"
// `new` is supported syntax; only the callee decides whether construction succeeds. A callee without
// construction support is a TypeError naming it, like JS, rather than an unsupported-syntax diagnostic
// that would suggest `new` itself is unavailable.
const tools = {
echo: Tool.make({
description: "Echo",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () => Effect.succeed({}),
}),
}
const run = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools }))
const value = async (code: string) => {
const result = await run(code)
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
return result.value
}
const error = async (code: string) => {
const result = await run(code)
if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`)
return result.error
}
describe("new on a non-constructible callee", () => {
test("built-in functions without construction point at the plain call", async () => {
// Number is a real constructor in JS, so the message must not claim otherwise.
const failure = await error(`return new Number(42)`)
expect(failure.kind).toBe("ExecutionFailure")
expect(failure.message).toStartWith("new Number(...) is not supported; call Number(...) without new instead.")
expect(failure.suggestions).toBeUndefined()
expect((await error(`return new String("a")`)).message).toStartWith("new String(...) is not supported")
expect((await error(`return new Math.abs(1)`)).message).toStartWith(
"new Math.abs(...) is not supported; call Math.abs(...) without new instead.",
)
})
test("non-callable values are not constructors", async () => {
expect((await error(`return new tools.echo()`)).message).toStartWith("tools.echo is not a constructor.")
expect((await error(`return new (1)()`)).message).toStartWith("The called value is not a constructor.")
expect((await error(`const Date = 5; return new Date()`)).message).toStartWith("Date is not a constructor.")
})
test("user-defined functions explain the documented gap", async () => {
const failure = await error(`function Point(x) { return { x } }; return new Point(1)`)
expect(failure.message).toStartWith(
"Point cannot be constructed: user-defined constructors and classes are not supported. Call it as a function that returns a plain object instead.",
)
expect((await error(`const make = () => ({}); return new make()`)).message).toStartWith(
"make cannot be constructed",
)
})
test("the failure is a catchable TypeError", async () => {
expect(
await value(`
try { new Number(1) } catch (error) { return [error.name, error instanceof TypeError] }
`),
).toEqual(["TypeError", true])
})
test("an undeclared callee still fails as an unknown identifier", async () => {
expect((await error(`return new Function("return 1")`)).message).toContain("Function")
expect((await error(`return new Function("return 1")`)).message).not.toContain("not a constructor")
})
test("classes remain unsupported syntax", async () => {
const failure = await error(`class A {}; return new A()`)
expect(failure.kind).toBe("UnsupportedSyntax")
expect(failure.message).toStartWith(
"Syntax 'ClassDeclaration' is not supported. This is a restricted JavaScript-like language. Supported: ",
)
expect(failure.message).toContain(
"Unsupported: classes, this, getters/setters, tagged templates, BigInt, and custom Symbols.",
)
})
})
@@ -1,217 +0,0 @@
/**
* Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75:
* - test/built-ins/Object/keys/15.2.3.14-1-1.js
* - test/built-ins/Object/keys/15.2.3.14-1-2.js
* - test/built-ins/Object/keys/15.2.3.14-1-3.js
* - test/built-ins/Object/keys/15.2.3.14-1-4.js
* - test/built-ins/Object/keys/15.2.3.14-1-5.js
* - test/built-ins/Object/entries/primitive-strings.js
* - test/built-ins/Object/entries/primitive-numbers.js
* - test/built-ins/Object/entries/primitive-booleans.js
* - test/built-ins/Object/values/primitive-strings.js
* - test/built-ins/Object/values/primitive-numbers.js
* - test/built-ins/Object/values/primitive-booleans.js
* - test/built-ins/Object/hasOwn/toobject_null.js
* - test/built-ins/Object/hasOwn/toobject_undefined.js
* - test/built-ins/Object/hasOwn/hasown_nonexistent.js
* - test/built-ins/Object/assign/Source-String.js
* - test/built-ins/Object/assign/Source-Null-Undefined.js
* - test/built-ins/Object/assign/target-Array.js
* - test/built-ins/Object/assign/Target-Null.js
* - test/built-ins/Object/assign/Target-Undefined.js
* - test/built-ins/Object/assign/Target-Object.js
* - test/built-ins/Object/assign/Override.js
* - test/built-ins/Object/assign/ObjectOverride-sameproperty.js
*
* Copyright (c) 2012 Ecma International. All rights reserved.
* Copyright (C) 2015 Jordan Harband. All rights reserved.
* Copyright 2015 Microsoft Corporation. All rights reserved.
* Copyright 2021 Jamie Kyle. All rights reserved.
* Test262 portions are governed by the BSD license in LICENSE.test262.
*
* Boxed-primitive cases (`Object.assign("a")`, `Object.assign(1, …)`) are omitted: CodeMode has no
* wrapper objects, so a primitive target is a TypeError rather than a boxed result. `Override.js`
* checks `Object.keys(result).length` instead of `Object.getOwnPropertyNames`. `target-Array.js`
* omits its named-key (`-0`, `1.5`, `4294967295`), `length`, and Proxy assertions: arrays here hold
* only indexed elements, so those keys are a TypeError (pinned below) rather than array properties.
*/
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { CodeMode } from "../src/index.js"
const value = async (code: string) => {
const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} }))
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
return result.value
}
const throwsTypeError = (expression: string) =>
value(`try { ${expression}; return "no throw" } catch (error) { return error.name }`)
describe("Object.keys Test262 parity", () => {
test("test/built-ins/Object/keys/15.2.3.14-1-{1,2,3}.js: primitives are coerced", async () => {
expect(await value(`return [Object.keys(0), Object.keys(true), Object.keys("abc")]`)).toEqual([
[],
[],
["0", "1", "2"],
])
})
test("test/built-ins/Object/keys/15.2.3.14-1-{4,5}.js: null and undefined throw TypeError", async () => {
expect(await throwsTypeError(`Object.keys(null)`)).toBe("TypeError")
expect(await throwsTypeError(`Object.keys(undefined)`)).toBe("TypeError")
})
})
describe("Object.entries and Object.values Test262 parity", () => {
test("test/built-ins/Object/entries/primitive-strings.js", async () => {
expect(
await value(`
const result = Object.entries('abc')
return [Array.isArray(result), result.length, result[0][0], result[0][1], result[1][0], result[1][1], result[2][0], result[2][1]]
`),
).toEqual([true, 3, "0", "a", "1", "b", "2", "c"])
})
test("test/built-ins/Object/entries/primitive-numbers.js", async () => {
expect(
await value(`
return [0, -0, Infinity, -Infinity, NaN, Math.PI].map((number) => Object.entries(number).length)
`),
).toEqual([0, 0, 0, 0, 0, 0])
})
test("test/built-ins/Object/entries/primitive-booleans.js", async () => {
expect(
await value(`
const trueResult = Object.entries(true)
const falseResult = Object.entries(false)
return [Array.isArray(trueResult), trueResult.length, Array.isArray(falseResult), falseResult.length]
`),
).toEqual([true, 0, true, 0])
})
test("test/built-ins/Object/values/primitive-strings.js", async () => {
expect(
await value(`
const result = Object.values('abc')
return [Array.isArray(result), result.length, result[0], result[1], result[2]]
`),
).toEqual([true, 3, "a", "b", "c"])
})
test("test/built-ins/Object/values/primitive-numbers.js", async () => {
expect(
await value(`
return [0, -0, Infinity, -Infinity, NaN, Math.PI].map((number) => Object.values(number).length)
`),
).toEqual([0, 0, 0, 0, 0, 0])
})
test("test/built-ins/Object/values/primitive-booleans.js", async () => {
expect(
await value(`
const trueResult = Object.values(true)
const falseResult = Object.values(false)
return [Array.isArray(trueResult), trueResult.length, Array.isArray(falseResult), falseResult.length]
`),
).toEqual([true, 0, true, 0])
})
})
describe("Object.hasOwn Test262 parity", () => {
test("test/built-ins/Object/hasOwn/toobject_{null,undefined}.js", async () => {
expect(await throwsTypeError(`Object.hasOwn(null, 'foo')`)).toBe("TypeError")
expect(await throwsTypeError(`Object.hasOwn(undefined, 'foo')`)).toBe("TypeError")
})
test("test/built-ins/Object/hasOwn/hasown_nonexistent.js", async () => {
expect(await value(`const o = {}; return Object.hasOwn(o, "foo")`)).toBe(false)
})
})
describe("Object.assign Test262 parity", () => {
test("test/built-ins/Object/assign/Source-String.js", async () => {
expect(
await value(`
const target = new Object()
const result = Object.assign(target, "123")
return [result[0], result[1], result[2]]
`),
).toEqual(["1", "2", "3"])
})
test("test/built-ins/Object/assign/Source-Null-Undefined.js", async () => {
expect(
await value(`
const target = new Object()
const result = Object.assign(target, undefined, null)
return result === target
`),
).toBe(true)
})
test("test/built-ins/Object/assign/target-Array.js", async () => {
expect(
await value(`
const target = [7, 8, 9]
let result = Object.assign(target, [1])
const first = [result === target, [...result]]
const sparseArraySource = []
sparseArraySource[2] = 3
result = Object.assign(target, sparseArraySource)
const second = [result === target, [...result]]
result = Object.assign(target, { 4: 0 })
return [...first, ...second, result === target, result.length, result[3] === undefined, result[4]]
`),
).toEqual([true, [1, 8, 9], true, [1, 8, 3], true, 5, true, 0])
})
test("array targets accept only array indexes (deviation from target-Array.js)", async () => {
expect(
await value(`
const target = [7]
const out = []
for (const source of [{ length: 0 }, { x: 1 }, { "1.5": 1 }, { "-0": 1 }, { ["__proto__"]: null }]) {
try { Object.assign(target, source) } catch (error) { out.push(error.name) }
}
return [out, [...target], target.length, Object.keys(target)]
`),
).toEqual([["TypeError", "TypeError", "TypeError", "TypeError", "TypeError"], [7], 1, ["0"]])
})
test("test/built-ins/Object/assign/Target-{Null,Undefined}.js", async () => {
expect(await throwsTypeError(`Object.assign(null, { a: 1 })`)).toBe("TypeError")
expect(await throwsTypeError(`Object.assign(undefined, { a: 1 })`)).toBe("TypeError")
})
test("test/built-ins/Object/assign/Target-Object.js", async () => {
expect(
await value(`
const target = { foo: 1 }
const result = Object.assign(target, { a: 2 })
return [result.foo, result.a]
`),
).toEqual([1, 2])
})
test("test/built-ins/Object/assign/Override.js", async () => {
expect(
await value(`
const target = { a: 1 }
const result = Object.assign(target, "1a2c3", { a: "c" }, undefined, { b: 6 }, null, 125, { a: 5 })
return [Object.keys(result).length, result.a, result[0], result[1], result[2], result[3], result[4], result.b]
`),
).toEqual([7, 5, "1", "a", "2", "c", "3", 6])
})
test("test/built-ins/Object/assign/ObjectOverride-sameproperty.js", async () => {
expect(
await value(`
const target = { a: 1 }
const result = Object.assign(target, { a: 2 }, { a: "c" })
return result.a
`),
).toBe("c")
})
})
+1 -1
View File
@@ -1095,7 +1095,7 @@ describe("OpenAPI.fromSpec", () => {
tags: ["x", "y"],
filter: { state: "open", page: 2 },
nullable: null,
constructor: "safe",
constructor_2: "safe",
meta: { a: "b", c: "d" },
})
.pipe(Effect.provide(client.layer)),
+17 -20
View File
@@ -118,12 +118,9 @@ describe("H6: object spread of null/undefined is a no-op", () => {
expect(await value(`const o = { a: 1 }; return { ...o, b: 2 }`)).toEqual({ a: 1, b: 2 })
})
test("spreading an array or string into an object copies index keys, like JS", async () => {
expect(await value(`return { ...[1,2], a: 1 }`)).toEqual({ 0: 1, 1: 2, a: 1 })
expect(await value(`return { ..."ab", ...5, ...true, ...(() => 1), ...new Map([[1, 2]]) }`)).toEqual({
0: "a",
1: "b",
})
test("spreading an array into an object still errors", async () => {
const err = await error(`return { ...[1,2], a: 1 }`)
expect(err.kind).toBe("InvalidDataValue")
})
})
@@ -268,11 +265,9 @@ describe("property deletion", () => {
expect((await error(`return delete tools.example`)).kind).toBe("InvalidDataValue")
})
test("prototype-named keys delete like any own data key", async () => {
expect(
await value(`const object = { __proto__: 1, a: 2 }; delete object.__proto__; return Object.keys(object)`),
).toEqual(["a"])
expect(await value(`const values = [1]; delete values["constructor"]; return values`)).toEqual([1])
test("keeps blocked property names unavailable", async () => {
expect((await error(`const object = {}; return delete object.__proto__`)).kind).toBe("ExecutionFailure")
expect((await error(`const values = []; return delete values["constructor"]`)).kind).toBe("ExecutionFailure")
})
})
@@ -491,8 +486,12 @@ describe("CodeMode-specific string behavior", () => {
expect(await value(`try { "x".normalize("nope"); return "no" } catch (e) { return e.message }`)).toContain('"NFC"')
})
test("exposes the Annex B string aliases every engine ships", async () => {
expect(await value(`return [" x ".trimLeft(), " x ".trimRight(), "abc".substr(1, 1)]`)).toEqual(["x ", " x", "b"])
test("does not expose obsolete string aliases", async () => {
expect(await value(`return [typeof "x".trimLeft, typeof "x".trimRight, typeof "x".substr]`)).toEqual([
"undefined",
"undefined",
"undefined",
])
})
})
@@ -907,12 +906,10 @@ describe("coercion parity: unknown static members read as undefined", () => {
)
})
test("prototype-named members on globals read as undefined like other unknown statics", async () => {
expect(await value(`return [Math.constructor, Number.constructor, Object.prototype, Array.__proto__]`)).toEqual([
null,
null,
null,
null,
])
test("blocked members still throw instead of reading as undefined", async () => {
const err = await error(`return Math.constructor`)
expect(err.message).toContain("not available")
const coercionErr = await error(`return Number.constructor`)
expect(coercionErr.message).toContain("Number.constructor is not available")
})
})
@@ -126,7 +126,7 @@ describe("RegExp Test262 parity", () => {
const match = /(?<a>a)(b)?/d.exec("a")
const stringMatch = "a".match(/a/d)
const all = "a a".matchAll(/a/dg)
const named = /(?<constructor>a)(?<safe>b)/d.exec("ab")
const blocked = /(?<constructor>a)(?<safe>b)/d.exec("ab")
return [
/./.hasIndices,
/./d.hasIndices,
@@ -138,10 +138,10 @@ describe("RegExp Test262 parity", () => {
stringMatch.indices[0],
all[0].indices[0],
all[1].indices[0],
Object.keys(named.indices.groups),
Object.keys(blocked.indices.groups),
]
`),
).toEqual([false, true, true, [0, 1], [0, 1], null, [0, 1], [0, 1], [0, 1], [2, 3], ["constructor", "safe"]])
).toEqual([false, true, true, [0, 1], [0, 1], null, [0, 1], [0, 1], [0, 1], [2, 3], ["safe"]])
})
test("match indices preserve captures, Unicode offsets, and groups properties", async () => {
@@ -177,13 +177,13 @@ describe("RegExp Test262 parity", () => {
])
})
test("match and matchAll preserve named, unmatched, and prototype-named index groups", async () => {
test("match and matchAll preserve named, unmatched, and blocked index groups", async () => {
expect(
await value(`
const matched = "a".match(/(?<a>a)|(?<x>x)/d).indices.groups
const all = "a x".matchAll(/(?<a>a)|(?<x>x)/dg)
const namedMatch = "ab".match(/(?<constructor>a)(?<safe>b)/d).indices.groups
const namedAll = "ab".matchAll(/(?<constructor>a)(?<safe>b)/dg)[0].indices.groups
const blockedMatch = "ab".match(/(?<constructor>a)(?<safe>b)/d).indices.groups
const blockedAll = "ab".matchAll(/(?<constructor>a)(?<safe>b)/dg)[0].indices.groups
return [
matched.a,
matched.x,
@@ -191,11 +191,11 @@ describe("RegExp Test262 parity", () => {
all[0].indices.groups.x,
all[1].indices.groups.a,
all[1].indices.groups.x,
Object.keys(namedMatch),
Object.keys(namedAll),
Object.keys(blockedMatch),
Object.keys(blockedAll),
]
`),
).toEqual([[0, 1], null, [0, 1], null, null, [2, 3], ["constructor", "safe"], ["constructor", "safe"]])
).toEqual([[0, 1], null, [0, 1], null, null, [2, 3], ["safe"], ["safe"]])
})
test("v flag exposes unicodeSets and remains exclusive with u", async () => {
+3 -3
View File
@@ -740,7 +740,7 @@ describe("JSDoc signatures in catalogs and search results", () => {
"}",
].join("\n")
const signature = `tools.constrained(input: ${type}): Promise<${type}>`
expect(runtime.catalog[0]?.signature).toBe(signature)
expect(runtime.catalog()[0]?.signature).toBe(signature)
const result = await Effect.runPromise(runtime.execute('return search({ query: "tools.constrained" })'))
expect(result.ok).toBe(true)
if (!result.ok) throw new Error("search failed")
@@ -796,7 +796,7 @@ describe("JSDoc signatures in catalogs and search results", () => {
})
test("the catalog uses the same JSDoc signatures as search", async () => {
const catalog = runtime.catalog
const catalog = runtime.catalog()
const github = (await search("list issues repository")).items.find(
({ path }) => path === "tools.github.list_issues",
)!
@@ -824,7 +824,7 @@ describe("non-identifier tool paths", () => {
const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } })
test("catalog signatures use bracket notation for dashed tool names", () => {
expect(runtime.catalog[0]?.signature).toBe(
expect(runtime.catalog()[0]?.signature).toBe(
'tools.context7["resolve-library-id"](input: {\n query: string,\n libraryName: string,\n}): Promise<unknown>',
)
})
+4 -17
View File
@@ -55,9 +55,7 @@ describe("Number and Math", () => {
})
test("Number valueOf does not enable boxed numbers", async () => {
const failure = await error(`return new Number(42)`)
expect(failure.kind).toBe("ExecutionFailure")
expect(failure.message).toContain("new Number(...) is not supported; call Number(...) without new instead.")
expect((await error(`return new Number(42)`)).kind).toBe("UnsupportedSyntax")
})
})
@@ -722,25 +720,14 @@ describe("Set", () => {
})
describe("stdlib integration", () => {
test("constructor follows own keys, shadowing, writes, and new", async () => {
expect(
await value(`return [JSON.parse('{"constructor":"Foo"}').constructor, ({ constructor: 1 }).constructor]`),
).toEqual(["Foo", 1])
expect(await value(`const Array = 5; return [].constructor.isArray([])`)).toBe(true)
expect(await value(`const o = {}; o.constructor = 7; return o.constructor`)).toBe(7)
expect(await value(`return new ([].constructor)(3).length`)).toBe(3)
expect(await value(`return typeof ({}).constructor`)).toBe("function")
expect(await value(`return ({}).constructor.constructor`)).toBeNull()
})
test("new dispatches on the constructor value, not its name", async () => {
expect(await value(`const D = Date; return new D(0) instanceof Date`)).toBe(true)
expect(await value(`const make = (C) => new C([["a", 1]]); return make(Map).get("a")`)).toBe(1)
expect(await value(`const t = { M: Map }; return new t.M() instanceof Map`)).toBe(true)
const shadowed = await error(`const Date = 5; return new Date()`)
expect(shadowed.message).toStartWith("Date is not a constructor.")
expect(shadowed.kind).toBe("UnsupportedSyntax")
const fn = await error(`const f = () => 1; return new f()`)
expect(fn.message).toStartWith("f cannot be constructed")
expect(fn.kind).toBe("UnsupportedSyntax")
})
test("Object.is uses SameValue semantics", async () => {
@@ -1130,7 +1117,7 @@ describe("CodeMode values at intra-CodeMode checkpoints", () => {
const diagnostic = await error(`return Object.keys(Promise.resolve({ a: 1 }))`)
expect(diagnostic.kind).toBe("InvalidDataValue")
expect(diagnostic.message).toContain("await")
expect(await value(`return Object.keys(Math)`)).toEqual([])
expect((await error(`return Object.keys(Math)`)).kind).toBe("InvalidDataValue")
})
test("Object.assign keeps Maps usable", async () => {
@@ -1,179 +0,0 @@
/**
* Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75:
* - test/annexB/built-ins/String/prototype/substr/length-falsey.js
* - test/annexB/built-ins/String/prototype/substr/length-negative.js
* - test/annexB/built-ins/String/prototype/substr/length-positive.js
* - test/annexB/built-ins/String/prototype/substr/length-undef.js
* - test/annexB/built-ins/String/prototype/substr/start-negative.js
* - test/annexB/built-ins/String/prototype/substr/surrogate-pairs.js
* - test/built-ins/String/prototype/isWellFormed/returns-boolean.js
* - test/built-ins/String/prototype/toWellFormed/returns-well-formed-string.js
* - test/built-ins/Date/prototype/toDateString/format.js
* - test/built-ins/Date/prototype/toDateString/invalid-date.js
* - test/built-ins/Date/prototype/toDateString/negative-year.js
* - test/built-ins/Date/prototype/toTimeString/format.js
* - test/built-ins/Date/prototype/toTimeString/invalid-date.js
*
* Copyright (C) 2016, 2017 the V8 project authors. All rights reserved.
* Copyright (C) 2018 Richard Gibson. All rights reserved.
* Copyright (C) 2022 Jordan Harband. All rights reserved.
* Test262 portions are governed by the BSD license in LICENSE.test262.
*
* The `typeof String.prototype.method` checks are replaced with `typeof "".method` because
* CodeMode has no prototype objects.
*/
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { CodeMode } from "../src/index.js"
const value = async (code: string) => {
const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} }))
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
return result.value
}
describe("String.prototype.substr Test262 parity", () => {
test("test/annexB/built-ins/String/prototype/substr/length-falsey.js", async () => {
expect(
await value(`
return [false, NaN, "", null].flatMap((length) => [0, 1, 2, 3].map((start) => "abc".substr(start, length)))
`),
).toEqual(Array(16).fill(""))
})
test("test/annexB/built-ins/String/prototype/substr/length-negative.js", async () => {
expect(
await value(`
return [0, 1, 2, 3].flatMap((start) => [-1, -2, -3, -4].map((length) => "abc".substr(start, length)))
`),
).toEqual(Array(16).fill(""))
})
test("test/annexB/built-ins/String/prototype/substr/length-positive.js", async () => {
expect(
await value(`
return [0, 1, 2, 3].map((start) => [1, 2, 3, 4].map((length) => "abc".substr(start, length)))
`),
).toEqual([
["a", "ab", "abc", "abc"],
["b", "bc", "bc", "bc"],
["c", "c", "c", "c"],
["", "", "", ""],
])
})
test("test/annexB/built-ins/String/prototype/substr/length-undef.js", async () => {
expect(
await value(`
return [
"abc".substr(0), "abc".substr(1), "abc".substr(2), "abc".substr(3),
"abc".substr(0, undefined), "abc".substr(1, undefined), "abc".substr(2, undefined), "abc".substr(3, undefined),
]
`),
).toEqual(["abc", "bc", "c", "", "abc", "bc", "c", ""])
})
test("test/annexB/built-ins/String/prototype/substr/start-negative.js", async () => {
expect(
await value(`
return ["abc".substr(-1), "abc".substr(-2), "abc".substr(-3), "abc".substr(-4), "abc".substr(-1.1)]
`),
).toEqual(["c", "bc", "abc", "abc", "c"])
})
test("test/annexB/built-ins/String/prototype/substr/surrogate-pairs.js", async () => {
expect(
await value(`
const pair = "\\ud834\\udf06"
return [pair.substr(0), pair.substr(1), pair.substr(2), pair.substr(0, 0), pair.substr(0, 1), pair.substr(0, 2)]
`),
).toEqual(["\ud834\udf06", "\udf06", "", "", "\ud834", "\ud834\udf06"])
})
})
describe("String well-formedness Test262 parity", () => {
test("test/built-ins/String/prototype/isWellFormed/returns-boolean.js", async () => {
expect(
await value(`
const leadingPoo = "\\uD83D"
const trailingPoo = "\\uDCA9"
const wholePoo = leadingPoo + trailingPoo
return [
typeof "".isWellFormed,
("a" + leadingPoo + "c" + leadingPoo + "e").isWellFormed(),
("a" + trailingPoo + "c" + trailingPoo + "e").isWellFormed(),
("a" + trailingPoo + leadingPoo + "d").isWellFormed(),
"a💩c".isWellFormed(),
"a\\uD83D\\uDCA9c".isWellFormed(),
("a" + leadingPoo + trailingPoo + "d").isWellFormed(),
wholePoo.slice(0, 1).isWellFormed(),
wholePoo.slice(1).isWellFormed(),
"abc".isWellFormed(),
"a\\u25A8c".isWellFormed(),
]
`),
).toEqual(["function", false, false, false, true, true, true, false, false, true, true])
})
test("test/built-ins/String/prototype/toWellFormed/returns-well-formed-string.js", async () => {
expect(
await value(`
const replacementChar = "\\uFFFD"
const leadingPoo = "\\uD83D"
const trailingPoo = "\\uDCA9"
const wholePoo = leadingPoo + trailingPoo
return [
typeof "".toWellFormed,
("a" + leadingPoo + "c" + leadingPoo + "e").toWellFormed() === "a" + replacementChar + "c" + replacementChar + "e",
("a" + trailingPoo + "c" + trailingPoo + "e").toWellFormed() === "a" + replacementChar + "c" + replacementChar + "e",
("a" + trailingPoo + leadingPoo + "d").toWellFormed() === "a" + replacementChar + replacementChar + "d",
"a💩c".toWellFormed() === "a💩c",
"a\\uD83D\\uDCA9c".toWellFormed() === "a\\uD83D\\uDCA9c",
("a" + leadingPoo + trailingPoo + "d").toWellFormed() === "a" + wholePoo + "d",
wholePoo.slice(0, 1).toWellFormed() === replacementChar,
wholePoo.slice(1).toWellFormed() === replacementChar,
"abc".toWellFormed() === "abc",
"a\\u25A8c".toWellFormed() === "a\\u25A8c",
]
`),
).toEqual(["function", true, true, true, true, true, true, true, true, true, true])
})
})
describe("Date string formatting Test262 parity", () => {
test("test/built-ins/Date/prototype/toDateString/format.js", async () => {
expect(
await value(`
const dateRegExp = /^(Sun|Mon|Tue|Wed|Thu|Fri|Sat) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) [0-9]{2} [0-9]{4}$/
return [dateRegExp.test(new Date(0).toDateString()), dateRegExp.test(new Date("0020-01-01T00:00:00Z").toDateString())]
`),
).toEqual([true, true])
})
test("test/built-ins/Date/prototype/toDateString/invalid-date.js", async () => {
expect(await value(`return new Date(NaN).toDateString()`)).toBe("Invalid Date")
})
test("test/built-ins/Date/prototype/toDateString/negative-year.js", async () => {
expect(
await value(`
return ["-000001", "-000012", "-000123", "-001234", "-012345", "-123456"].map(
(year) => new Date(year + "-07-01T00:00Z").toDateString().split(" ")[3],
)
`),
).toEqual(["-0001", "-0012", "-0123", "-1234", "-12345", "-123456"])
})
test("test/built-ins/Date/prototype/toTimeString/format.js", async () => {
expect(
await value(`
const timeRegExp = /^[0-9]{2}:[0-9]{2}:[0-9]{2} GMT[+-][0-9]{4}( \\(.+\\))?$/
return timeRegExp.test(new Date(0).toTimeString())
`),
).toBe(true)
})
test("test/built-ins/Date/prototype/toTimeString/invalid-date.js", async () => {
expect(await value(`return new Date(NaN).toTimeString()`)).toBe("Invalid Date")
})
})
@@ -1,245 +0,0 @@
/**
* Portions adapted from web-platform-tests at revision 863077959ca8c1a7ceecfbe2534b75d2527b9013:
* - html/webappapis/structured-clone/structured-clone-battery-of-tests.js
*
* Copyright © web-platform-tests contributors. Governed by the 3-Clause BSD license in LICENSE.wpt.
*
* The battery's `check(description, input, compare)` shape and its `compare_*` helpers are kept, run
* inside the interpreter. Ported: primitives, Array/Object of primitives, Date, RegExp, Error, sparse
* arrays, identical (shared) property values, and the index-property-plus-length object. Not portable:
* boxed primitives, BigInt, Blob/File/ImageData/ArrayBuffer/typed arrays (no binary values), circular
* references (rejected at insertion here), property descriptors and prototype properties (no
* defineProperty or prototypes), and the throwing-getter case (no getters). `assert_throws_dom` for
* `DataCloneError` becomes an `error.name` check.
*/
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { CodeMode } from "../src/index.js"
const value = async (code: string) => {
const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} }))
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
return result.value
}
// The WPT harness, minus async: assertions push a failure description instead of throwing so one run
// reports every failing check.
const harness = `
const failures = []
const assert_equals = (a, b, m) => { if (!Object.is(a, b) && !(a !== a && b !== b)) failures.push((m ?? "") + ": " + String(a) + " !== " + String(b)) }
const assert_not_equals = (a, b, m) => { if (a === b) failures.push((m ?? "") + ": unexpectedly identical") }
const assert_true = (a, m) => { if (a !== true) failures.push((m ?? "") + ": not true") }
const assert_false = (a, m) => { if (a !== false) failures.push((m ?? "") + ": not false") }
let current = ""
function check(description, input, callback) {
current = description
const newInput = typeof input === "function" ? input() : input
const copy = structuredClone(newInput)
const before = failures.length
callback(copy, newInput)
for (let i = before; i < failures.length; i++) failures[i] = description + " — " + failures[i]
}
function compare_primitive(actual, input) { assert_equals(actual, input) }
function compare_Array(callback) {
return function (actual, input) {
assert_true(Array.isArray(actual), "instanceof Array")
assert_not_equals(actual, input)
assert_equals(actual.length, input.length, "length")
callback(actual, input)
}
}
function compare_Object(callback) {
return function (actual, input) {
assert_true(actual instanceof Object, "instanceof Object")
assert_false(Array.isArray(actual), "instanceof Array")
assert_not_equals(actual, input)
callback(actual, input)
}
}
function enumerate_props(compare_func) {
return function (actual, input) { for (const x in input) compare_func(actual[x], input[x]) }
}
`
describe("structuredClone WPT battery", () => {
test("primitives, and arrays and objects of primitives", async () => {
expect(
await value(`
${harness}
check('primitive undefined', undefined, compare_primitive)
check('primitive null', null, compare_primitive)
check('primitive true', true, compare_primitive)
check('primitive false', false, compare_primitive)
check('primitive string, empty string', '', compare_primitive)
check('primitive string, lone high surrogate', '\\uD800', compare_primitive)
check('primitive string, lone low surrogate', '\\uDC00', compare_primitive)
check('primitive string, NUL', '\\u0000', compare_primitive)
check('primitive string, astral character', '\\uDBFF\\uDFFD', compare_primitive)
check('primitive number, 0.2', 0.2, compare_primitive)
check('primitive number, 0', 0, compare_primitive)
check('primitive number, -0', -0, compare_primitive)
check('primitive number, NaN', NaN, compare_primitive)
check('primitive number, Infinity', Infinity, compare_primitive)
check('primitive number, -Infinity', -Infinity, compare_primitive)
check('primitive number, 9007199254740992', 9007199254740992, compare_primitive)
check('primitive number, -9007199254740992', -9007199254740992, compare_primitive)
check('primitive number, 9007199254740994', 9007199254740994, compare_primitive)
check('primitive number, -9007199254740994', -9007199254740994, compare_primitive)
check('Array primitives', [undefined, null, true, false, '', '\\uD800', '\\uDC00', '\\u0000', '\\uDBFF\\uDFFD',
0.2, 0, -0, NaN, Infinity, -Infinity, 9007199254740992, -9007199254740992, 9007199254740994, -9007199254740994],
compare_Array(enumerate_props(compare_primitive)))
check('Object primitives', { 'undefined': undefined, 'null': null, 'true': true, 'false': false, 'empty': '',
'high surrogate': '\\uD800', 'low surrogate': '\\uDC00', 'nul': '\\u0000', 'astral': '\\uDBFF\\uDFFD',
'0.2': 0.2, '0': 0, '-0': -0, 'NaN': NaN, 'Infinity': Infinity, '-Infinity': -Infinity,
'9007199254740992': 9007199254740992, '-9007199254740992': -9007199254740992,
'9007199254740994': 9007199254740994, '-9007199254740994': -9007199254740994 },
compare_Object(enumerate_props(compare_primitive)))
return failures
`),
).toEqual([])
})
test("Date", async () => {
expect(
await value(`
${harness}
function compare_Date(actual, input) {
assert_true(actual instanceof Date, 'instanceof Date')
assert_equals(Number(actual), Number(input), 'converted to primitive')
assert_not_equals(actual, input)
}
check('Date 0', new Date(0), compare_Date)
check('Date -0', new Date(-0), compare_Date)
check('Date -8.64e15', new Date(-8.64e15), compare_Date)
check('Date 8.64e15', new Date(8.64e15), compare_Date)
check('Array Date objects', [new Date(0), new Date(-0), new Date(-8.64e15), new Date(8.64e15)],
compare_Array(enumerate_props(compare_Date)))
check('Object Date objects', { '0': new Date(0), '-0': new Date(-0), '-8.64e15': new Date(-8.64e15), '8.64e15': new Date(8.64e15) },
compare_Object(enumerate_props(compare_Date)))
return failures
`),
).toEqual([])
})
test("RegExp: flags copied, lastIndex reset, source escaped", async () => {
expect(
await value(`
${harness}
function compare_RegExp(expected_source) {
return function (actual, input) {
assert_true(actual instanceof RegExp, 'instanceof RegExp')
assert_equals(actual.global, input.global, 'global')
assert_equals(actual.ignoreCase, input.ignoreCase, 'ignoreCase')
assert_equals(actual.multiline, input.multiline, 'multiline')
assert_equals(actual.source, expected_source, 'source')
assert_equals(actual.sticky, input.sticky, 'sticky')
assert_equals(actual.unicode, input.unicode, 'unicode')
assert_equals(actual.lastIndex, 0, 'lastIndex')
assert_not_equals(actual, input)
}
}
function func_RegExp_flags_lastIndex() {
const r = /foo/gim
r.lastIndex = 2
return r
}
function func_RegExp_sticky() { return new RegExp('foo', 'y') }
function func_RegExp_unicode() { return new RegExp('foo', 'u') }
check('RegExp flags and lastIndex', func_RegExp_flags_lastIndex, compare_RegExp('foo'))
check('RegExp sticky flag', func_RegExp_sticky, compare_RegExp('foo'))
check('RegExp unicode flag', func_RegExp_unicode, compare_RegExp('foo'))
check('RegExp empty', new RegExp(''), compare_RegExp('(?:)'))
check('RegExp slash', new RegExp('/'), compare_RegExp('\\\\/'))
check('RegExp new line', new RegExp('\\n'), compare_RegExp('\\\\n'))
check('Array RegExp object, RegExp flags and lastIndex', [func_RegExp_flags_lastIndex()], compare_Array(enumerate_props(compare_RegExp('foo'))))
check('Array RegExp object, RegExp sticky flag', function () { return [func_RegExp_sticky()] }, compare_Array(enumerate_props(compare_RegExp('foo'))))
check('Array RegExp object, RegExp unicode flag', function () { return [func_RegExp_unicode()] }, compare_Array(enumerate_props(compare_RegExp('foo'))))
check('Array RegExp object, RegExp empty', [new RegExp('')], compare_Array(enumerate_props(compare_RegExp('(?:)'))))
check('Array RegExp object, RegExp slash', [new RegExp('/')], compare_Array(enumerate_props(compare_RegExp('\\\\/'))))
check('Array RegExp object, RegExp new line', [new RegExp('\\n')], compare_Array(enumerate_props(compare_RegExp('\\\\n'))))
check('Object RegExp object, RegExp flags and lastIndex', { 'x': func_RegExp_flags_lastIndex() }, compare_Object(enumerate_props(compare_RegExp('foo'))))
check('Object RegExp object, RegExp sticky flag', function () { return { 'x': func_RegExp_sticky() } }, compare_Object(enumerate_props(compare_RegExp('foo'))))
check('Object RegExp object, RegExp unicode flag', function () { return { 'x': func_RegExp_unicode() } }, compare_Object(enumerate_props(compare_RegExp('foo'))))
check('Object RegExp object, RegExp empty', { 'x': new RegExp('') }, compare_Object(enumerate_props(compare_RegExp('(?:)'))))
check('Object RegExp object, RegExp slash', { 'x': new RegExp('/') }, compare_Object(enumerate_props(compare_RegExp('\\\\/'))))
check('Object RegExp object, RegExp new line', { 'x': new RegExp('\\n') }, compare_Object(enumerate_props(compare_RegExp('\\\\n'))))
return failures
`),
).toEqual([])
})
test("Error: name and message kept, custom properties dropped", async () => {
expect(
await value(`
${harness}
function compare_Error(actual, input) {
assert_true(actual instanceof Error, "Checking instanceof")
assert_equals(actual.name, input.name, "Checking name")
assert_equals(Object.hasOwn(actual, "message"), Object.hasOwn(input, "message"), "Checking message existence")
assert_equals(actual.message, input.message, "Checking message")
assert_equals(actual.foo, undefined, "Checking for absence of custom property")
}
check('Empty Error object', new Error(), compare_Error)
for (const constructor of [Error, RangeError, ReferenceError, SyntaxError, TypeError, URIError]) {
check(constructor.name, () => {
const error = new constructor("Error message here")
error.foo = "testing"
return error
}, compare_Error)
}
return failures
`),
).toEqual([])
})
test("sparse arrays, index-property objects, and identical property values", async () => {
expect(
await value(`
${harness}
check('Array sparse', new Array(10), compare_Array(enumerate_props(compare_primitive)))
check('Object with index property and length', { '0': 'foo', 'length': 1 }, compare_Object(enumerate_props(compare_primitive)))
function check_identical_property_values(prop1, prop2) {
return function (actual) { assert_equals(actual[prop1], actual[prop2]) }
}
check('Array with identical property values', function () {
const obj = {}
return [obj, obj]
}, compare_Array(check_identical_property_values('0', '1')))
check('Object with identical property values', function () {
const obj = {}
return { 'x': obj, 'y': obj }
}, compare_Object(check_identical_property_values('x', 'y')))
return failures
`),
).toEqual([])
})
})
describe("structuredClone beyond the WPT battery", () => {
test("Map, Set, URL, and URLSearchParams are copied, with shared references preserved across containers", async () => {
expect(
await value(`
const shared = { n: 1 }
const input = { m: new Map([[shared, shared]]), s: new Set([shared]), u: new URL("https://a.b/c?d=1") }
const copy = structuredClone(input)
const [[key, item]] = [...copy.m]
copy.u.searchParams.set("d", "2")
return [
copy.m !== input.m, key !== shared, key === item, key === [...copy.s][0],
copy.u !== input.u, input.u.href, copy.u.href,
]
`),
).toEqual([true, true, true, true, true, "https://a.b/c?d=1", "https://a.b/c?d=2"])
})
test("functions, promises, and tool references throw DataCloneError", async () => {
expect(
await value(`
return [() => 1, Promise.resolve(1), tools, Math, { nested: [() => 1] }].map((input) => {
try { structuredClone(input); return "cloned" } catch (error) { return error.name }
})
`),
).toEqual(Array(5).fill("DataCloneError"))
expect(await value(`try { structuredClone() } catch (error) { return error.name }`)).toBe("TypeError")
})
})
+13 -34
View File
@@ -26,7 +26,7 @@ describe("dotted tool names", () => {
const runtime = CodeMode.make({ tools: { api: { "issues.list": echo("List issues", "listed") } } })
test("a dotted name becomes nested namespaces in the catalog", () => {
const catalog = runtime.catalog
const catalog = runtime.catalog()
expect(catalog).toHaveLength(1)
expect(catalog[0]?.path).toBe("api.issues.list")
expect(catalog[0]?.signature).toStartWith("tools.api.issues.list(")
@@ -51,7 +51,7 @@ describe("dotted tool names", () => {
test("a top-level dotted name nests from the root", async () => {
const flat = CodeMode.make({ tools: { "issues.list": echo("List issues", "flat") } })
expect(flat.catalog[0]?.path).toBe("issues.list")
expect(flat.catalog()[0]?.path).toBe("issues.list")
expect(await value(flat, `return await tools.issues.list({})`)).toBe("flat")
})
@@ -85,7 +85,7 @@ describe("callable namespaces", () => {
test("a path can hold a tool and child tools at once", async () => {
expect(await value(runtime, `return await tools.issues({})`)).toBe("all")
expect(await value(runtime, `return await tools.issues.list({})`)).toBe("list")
expect(runtime.catalog.map((tool) => tool.path)).toEqual(["issues", "issues.list"])
expect(runtime.catalog().map((tool) => tool.path)).toEqual(["issues", "issues.list"])
})
test("a callable namespace enumerates its children", async () => {
@@ -145,7 +145,7 @@ describe("tool input diagnostics", () => {
test("an empty-input tool advertises () and runs with zero arguments", async () => {
const empty = CodeMode.make({ tools: { ping: echo("Ping", "pong") } })
expect(empty.catalog[0]?.signature).toBe("tools.ping(): Promise<string>")
expect(empty.catalog()[0]?.signature).toBe("tools.ping(): Promise<string>")
expect(await value(empty, `return await tools.ping()`)).toBe("pong")
})
})
@@ -160,7 +160,7 @@ describe("blocked member names on tool paths", () => {
})
test("tools may use blocked member names because path segments never touch real properties", async () => {
expect(runtime.catalog.map((tool) => tool.path)).toEqual(["issues.constructor", "nested.__proto__", "prototype"])
expect(runtime.catalog().map((tool) => tool.path)).toEqual(["issues.constructor", "nested.__proto__", "prototype"])
expect(await value(runtime, `return await tools.prototype({})`)).toBe("proto")
expect(await value(runtime, `return await tools.issues.constructor({})`)).toBe("ctor")
expect(await value(runtime, `return await tools["issues.constructor"]({})`)).toBe("ctor")
@@ -172,35 +172,14 @@ describe("blocked member names on tool paths", () => {
const poisoned = CodeMode.make({
tools: { ns: { __proto__: echo("Hidden", "hidden"), real: echo("Real tool", "real") } },
})
expect(poisoned.catalog.map((tool) => tool.path)).toEqual(["ns.real"])
expect(poisoned.catalog().map((tool) => tool.path)).toEqual(["ns.real"])
expect(await value(poisoned, `return await tools.ns.real({})`)).toBe("real")
})
test("prototype machinery is unreachable through data values", async () => {
expect(
await value(
runtime,
`
const object = {}
const array = []
object.__proto__ = { polluted: true }
return [
object.constructor === Object, array.constructor === Array, "".constructor === String, Math.constructor,
object.__proto__.polluted, ({}).polluted, array.__proto__, Object().__proto__, new Object().constructor === Object,
({}).constructor.constructor, [].constructor.__proto__, typeof [].__proto__,
]
`,
),
).toEqual([true, true, true, null, true, null, null, null, true, null, null, "undefined"])
expect((await failure(runtime, `return (() => 1).constructor`)).message).toContain(
"Cannot read properties of a function",
)
const escape = await failure(runtime, `return ({}).constructor.constructor.constructor("return 1")()`)
expect(escape.message).toContain("Cannot access a property on a non-object value")
const poisoned = await failure(runtime, `const o = {}; o.__proto__.constructor("return 1")`)
expect(poisoned.message).toContain("Cannot access a property on a non-object value")
test("blocked member access on data values stays blocked", async () => {
const diagnostic = await failure(runtime, `const x = {}; return x.constructor`)
expect(diagnostic.message).toContain("constructor")
expect(Object.keys(Object.prototype)).toEqual([])
expect(Object.keys(Array.prototype)).toEqual([])
})
})
@@ -221,7 +200,7 @@ describe("namespace metadata", () => {
const runtime = CodeMode.make({ tools })
test("the wrapper does not add a segment to callable paths", async () => {
expect(runtime.catalog.map((tool) => tool.path)).toEqual(["api.status", "api.users.list", "plain.read"])
expect(runtime.catalog().map((tool) => tool.path)).toEqual(["api.status", "api.users.list", "plain.read"])
expect(await value(runtime, `return await tools.api.users.list({})`)).toBe("users")
})
@@ -260,8 +239,8 @@ describe("canonical path collisions", () => {
tools: { "issues.list": echo("First", "first"), issues: { list: echo("Second", "second") } },
})
expect(await value(runtime, `return await tools.issues.list({})`)).toBe("second")
expect(runtime.catalog).toHaveLength(1)
expect(runtime.catalog[0]?.description).toBe("Second")
expect(runtime.catalog()).toHaveLength(1)
expect(runtime.catalog()[0]?.description).toBe("Second")
})
test("overriding one path keeps sibling tools from both shapes", async () => {
@@ -272,7 +251,7 @@ describe("canonical path collisions", () => {
"issues.close": echo("Close issue", "closed"),
},
})
expect(runtime.catalog.map((tool) => tool.path)).toEqual(["issues.close", "issues.get", "issues.list"])
expect(runtime.catalog().map((tool) => tool.path)).toEqual(["issues.close", "issues.get", "issues.list"])
expect(await value(runtime, `return await tools.issues.list({})`)).toBe("second")
expect(await value(runtime, `return await tools.issues.get({})`)).toBe("got")
expect(await value(runtime, `return await tools.issues.close({})`)).toBe("closed")
@@ -1,242 +0,0 @@
/**
* Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75:
* - test/language/statements/variable/S12.2_A1.js
* - test/language/statements/variable/S12.2_A3.js
* - test/language/statements/variable/S12.2_A6_T1.js
* - test/language/statements/variable/S12.2_A7.js
* - test/language/statements/variable/S12.2_A10.js
* - test/language/statements/variable/S12.2_A12.js
* - test/language/block-scope/shadowing/hoisting-var-declarations-out-of-blocks.js
* - test/language/block-scope/shadowing/catch-parameter-shadowing-var-variable.js
* - test/language/statements/for/head-var-bound-names-in-stmt.js
* - test/language/statements/function/scope-paramsbody-var-open.js
* - test/language/statements/function/scope-paramsbody-var-close.js
*
* Copyright 2009 the Sputnik authors. All rights reserved.
* Copyright (C) 2011, 2016 the V8 project authors. All rights reserved.
* Test262 portions are governed by the BSD license in LICENSE.test262.
*
* Files that observe `var` through `eval`, `this`, `delete`, or the global object (S12.2_A2, A5, A9,
* A11, `scope-*-none.js`, `scope-param-elem-*.js`) have no analogue here and are not ported.
*/
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { CodeMode } from "../src/index.js"
const value = async (code: string) => {
const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} }))
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
return result.value
}
describe("var hoisting Test262 parity", () => {
test("test/language/statements/variable/S12.2_A1.js: use before declaration reads undefined", async () => {
expect(
await value(`
__x = __x
__y = __x ? "good fellow" : "liar"
__z = __z === __x ? 1 : 0
let unknown
try { __something__undefined = __something__undefined } catch (error) { unknown = error.name }
const before = [__y, __z, unknown]
var __x, __y = true, __z = __y ? "smeagol" : "golum"
return [...before, __y, __z]
`),
).toEqual(["liar", 1, "ReferenceError", true, "smeagol"])
})
test("test/language/statements/variable/S12.2_A3.js: nested functions redeclare or assign", async () => {
expect(
await value(`
var __var = "OUT"
const inner = (function () {
var __var = "IN"
;(function () { __var = "INNER_SPACE" })()
;(function () { var __var = "INNER_SUN" })()
return __var
})()
const after = __var
const assigned = (function () {
__var = "IN"
;(function () { __var = "INNERED" })()
;(function () { var __var = "INNAGER" })()
return __var
})()
return [inner, after, assigned, __var]
`),
).toEqual(["INNER_SPACE", "OUT", "INNERED", "INNERED"])
})
test("test/language/statements/variable/S12.2_A6_T1.js: var inside try and catch is hoisted", async () => {
expect(
await value(`
intry__var = intry__var
incatch__var = incatch__var
try { var intry__var } catch (e) { var incatch__var }
return [typeof intry__var, typeof incatch__var]
`),
).toEqual(["undefined", "undefined"])
})
test("test/language/statements/variable/S12.2_A7.js: var after break inside for is hoisted", async () => {
expect(
await value(`
infor_var = infor_var
for (;;) { break; var infor_var }
return typeof infor_var
`),
).toBe("undefined")
})
test("test/language/statements/variable/S12.2_A10.js: var in for head is hoisted", async () => {
expect(
await value(`
__ind = __ind
for (var __ind; ; ) { break }
return typeof __ind
`),
).toBe("undefined")
})
test("test/language/statements/variable/S12.2_A12.js: var in do-while body is hoisted", async () => {
expect(
await value(`
x = x
do var x; while (false)
return typeof x
`),
).toBe("undefined")
})
test("test/language/block-scope/shadowing/hoisting-var-declarations-out-of-blocks.js", async () => {
expect(
await value(`
function fn() {
{ var x = 1; var y }
return [x, typeof y]
}
return fn()
`),
).toEqual([1, "undefined"])
})
test("test/language/block-scope/shadowing/catch-parameter-shadowing-var-variable.js", async () => {
expect(
await value(`
function fn() {
var a = 1
let caught
try { throw "stuff3" } catch (a) { caught = a }
return [caught, a]
}
return fn()
`),
).toEqual(["stuff3", 1])
})
test("test/language/statements/for/head-var-bound-names-in-stmt.js: redeclaring the head var in the body", async () => {
expect(
await value(`
var iterCount = 0
var first = true
for (var x; first; first = false) {
var x
iterCount += 1
}
return iterCount
`),
).toBe(1)
})
test("test/language/statements/function/scope-paramsbody-var-open.js: parameter defaults see the outer var", async () => {
expect(
await value(`
var x = "outside"
var probeParams, probeBody
function f(_ = probeParams = function () { return x }) {
var x = "inside"
probeBody = function () { return x }
}
f()
return [probeParams(), probeBody()]
`),
).toEqual(["outside", "inside"])
})
test("test/language/statements/function/scope-paramsbody-var-close.js: body var does not leak out", async () => {
expect(
await value(`
var probe
function f(_ = null) {
var x = "inside"
probe = function () { return x }
}
f()
var x = "outside"
return [probe(), x]
`),
).toEqual(["inside", "outside"])
})
})
describe("var semantics beyond Test262", () => {
test("redeclaration and block-level var assign the one function-scoped binding", async () => {
expect(await value(`var a = 1; var a = 2; { var a = 3 } return a`)).toBe(3)
expect(await value(`var q = 1; { let q = 2 } return q`)).toBe(1)
})
test("var loop counters are shared by closures, let counters are not", async () => {
expect(
await value(`
const byVar = []
for (var i = 0; i < 3; i++) byVar.push(() => i)
const byLet = []
for (let j = 0; j < 3; j++) byLet.push(() => j)
return [byVar.map((f) => f()), byLet.map((f) => f())]
`),
).toEqual([
[3, 3, 3],
[0, 1, 2],
])
})
test("for...in and for...of var heads survive the loop", async () => {
expect(await value(`for (var k in { a: 1 }) {} for (var [p, q] of [[1, 2]]) {} return [k, p, q]`)).toEqual([
"a",
1,
2,
])
})
test("var and function declarations of the same name share a binding", async () => {
expect(await value(`var fn = 1; function fn() {} return typeof fn`)).toBe("number")
expect(await value(`function fn() {} var fn; return typeof fn`)).toBe("function")
expect(await value(`function h() { var fn = 1; function fn() {} return typeof fn } return h()`)).toBe("number")
})
test("a var named after a parameter keeps the argument until assigned", async () => {
expect(await value(`function f(a) { var a; return a } return f(7)`)).toBe(7)
expect(await value(`function f(a) { var a = 2; return a } return f(7)`)).toBe(2)
})
test("var does not hoist across function boundaries", async () => {
expect(await value(`return [typeof b, (() => { var b = 1; return b })()]; var b`)).toEqual(["undefined", 1])
expect(
await value(
`function outer() { var o = 1; function inner() { var o = 2; return o } return [inner(), o] } return outer()`,
),
).toEqual([2, 1])
})
test("switch cases, labels, and generators hoist var", async () => {
expect(await value(`switch (1) { case 1: var s = 9 } label: { var lb = 1 } return [s, lb]`)).toEqual([9, 1])
expect(await value(`function* gen() { var t = 1; yield t; var t = 2; yield t } return [...gen()]`)).toEqual([1, 2])
})
})
describe("switch case function hoisting", () => {
test("function declarations are visible across all cases before their statement runs", async () => {
expect(await value(`switch (1) { case 1: return foo(); function foo() { return "hoisted" } }`)).toBe("hoisted")
expect(await value(`switch (2) { case 1: function foo() { return "a" } break; case 2: return foo() }`)).toBe("a")
})
})
-169
View File
@@ -1,169 +0,0 @@
/**
* Portions adapted from web-platform-tests at revision 863077959ca8c1a7ceecfbe2534b75d2527b9013:
* - 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
*
* Copyright © web-platform-tests contributors. Governed by the 3-Clause BSD license in LICENSE.wpt.
*
* `assert_throws_dom("InvalidCharacterError", …)` becomes a check on `error.name`: CodeMode has no
* DOMException, so the name is carried on a plain Error.
*/
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { CodeMode } from "../src/index.js"
const base64Cases = (await Bun.file(new URL("./fixtures/wpt-base64.json", import.meta.url)).json()) as Array<
[string, Array<number> | null]
>
const value = async (code: string) => {
const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} }))
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
return result.value
}
// The reference encoder from base64.any.js, run inside the interpreter so btoa is checked against
// an independent implementation rather than against the host's btoa.
const referenceEncoder = `
function btoaLookup(idx) {
if (idx < 26) return String.fromCharCode(idx + "A".charCodeAt(0))
if (idx < 52) return String.fromCharCode(idx - 26 + "a".charCodeAt(0))
if (idx < 62) return String.fromCharCode(idx - 52 + "0".charCodeAt(0))
if (idx == 62) return "+"
if (idx == 63) return "/"
}
function mybtoa(s) {
s = String(s)
for (var i = 0; i < s.length; i++) if (s.charCodeAt(i) > 255) return "INVALID_CHARACTER_ERR"
var out = ""
for (var i = 0; i < s.length; i += 3) {
var groupsOfSix = [undefined, undefined, undefined, undefined]
groupsOfSix[0] = s.charCodeAt(i) >> 2
groupsOfSix[1] = (s.charCodeAt(i) & 0x03) << 4
if (s.length > i + 1) {
groupsOfSix[1] |= s.charCodeAt(i + 1) >> 4
groupsOfSix[2] = (s.charCodeAt(i + 1) & 0x0f) << 2
}
if (s.length > i + 2) {
groupsOfSix[2] |= s.charCodeAt(i + 2) >> 6
groupsOfSix[3] = s.charCodeAt(i + 2) & 0x3f
}
for (var j = 0; j < groupsOfSix.length; j++) {
out += typeof groupsOfSix[j] == "undefined" ? "=" : btoaLookup(groupsOfSix[j])
}
}
return out
}
function testBtoa(input) {
var expected = mybtoa(input)
if (expected === "INVALID_CHARACTER_ERR") {
try { btoa(input) } catch (error) { return error.name === "InvalidCharacterError" ? "ok" : error.name }
return "did not throw"
}
if (btoa(input) !== expected) return "btoa mismatch"
if (atob(btoa(input)) !== String(input)) return "roundtrip mismatch"
return "ok"
}
`
describe("btoa WPT parity (html/webappapis/atob/base64.any.js)", () => {
test("every input encodes like the reference encoder and round-trips through atob", async () => {
expect(
await value(`
${referenceEncoder}
var tests = ["עברית", "", "ab", "abc", "abcd", "abcde", "\\xff\\xff\\xc0", "\\0a", "a\\0b",
undefined, null, 7, 12, 1.5, true, false, NaN, +Infinity, -Infinity, 0, -0]
for (var i = 0; i < 258; i++) tests.push(String.fromCharCode(i))
tests.push(String.fromCharCode(10000), String.fromCharCode(65534), String.fromCharCode(65535))
tests.push(String.fromCharCode(0xd800, 0xdc00))
var everything = ""
for (var i = 0; i < 256; i++) everything += String.fromCharCode(i)
tests.push(everything)
return tests.map(testBtoa).filter((outcome) => outcome !== "ok")
`),
).toEqual([])
})
})
describe("atob WPT parity (fetch/data-urls/resources/base64.json)", () => {
const idlCases: Array<[unknown, Array<number> | null]> = [
[undefined, null],
[null, [158, 233, 101]],
[7, null],
[12, [215]],
[1.5, null],
[true, [182, 187]],
[false, null],
[NaN, [53, 163]],
[Infinity, [34, 119, 226, 158, 43, 114]],
[-Infinity, null],
[0, null],
[-0, null],
]
test(`${base64Cases.length} forgiving-base64 inputs decode to the expected bytes or throw InvalidCharacterError`, async () => {
expect(
await value(`
const cases = ${JSON.stringify(base64Cases)}
return cases.flatMap(([input, output]) => {
try {
const result = atob(input)
if (output === null) return [[input, "expected throw"]]
const bytes = Array.from({ length: result.length }, (_, i) => result.charCodeAt(i))
return JSON.stringify(bytes) === JSON.stringify(output) ? [] : [[input, bytes]]
} catch (error) {
return output === null && error.name === "InvalidCharacterError" ? [] : [[input, error.name]]
}
})
`),
).toEqual([])
})
test("WebIDL argument conversion stringifies non-string inputs", async () => {
const literal = (input: unknown) =>
Object.is(input, -0)
? "-0"
: typeof input === "number" || input === undefined
? String(input)
: JSON.stringify(input)
expect(
await value(`
const cases = [${idlCases.map(([input, output]) => `[${literal(input)}, ${JSON.stringify(output)}]`).join(",")}]
return cases.flatMap(([input, output]) => {
try {
const result = atob(input)
if (output === null) return [[String(input), "expected throw"]]
// The source loop checks only the listed prefix of the decoded bytes.
const bytes = output.map((_, i) => result.charCodeAt(i))
return JSON.stringify(bytes) === JSON.stringify(output) ? [] : [[String(input), bytes]]
} catch (error) {
return output === null && error.name === "InvalidCharacterError" ? [] : [[String(input), error.name]]
}
})
`),
).toEqual([])
})
})
describe("crypto.randomUUID WPT parity (WebCryptoAPI/randomUUID.https.any.js)", () => {
test("namespace format, version, and variant bits over 256 iterations without collision", async () => {
expect(
await value(`
const uuids = new Set()
const randomUUID = () => {
const uuid = crypto.randomUUID()
if (uuids.has(uuid)) throw new Error("uuid collision " + uuid)
uuids.add(uuid)
return uuid
}
const UUIDRegex = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/
let format = true, version = true, variant = true
for (let i = 0; i < 256; i++) format = format && UUIDRegex.test(randomUUID())
for (let i = 0; i < 256; i++) version = version && (parseInt(randomUUID().split("-")[2].slice(0, 2), 16) & 0b11110000) === 0b01000000
for (let i = 0; i < 256; i++) variant = variant && (parseInt(randomUUID().split("-")[3].slice(0, 2), 16) & 0b11000000) === 0b10000000
return [format, version, variant, uuids.size]
`),
).toEqual([true, true, true, 768])
})
})

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