Compare commits

..
118 changed files with 1275 additions and 4140 deletions
+6 -1
View File
@@ -24,11 +24,16 @@ jobs:
- name: Run stats script
run: bun script/stats.ts
- name: Update GitHub stars
run: bun script/update-github-stars.ts
env:
GITHUB_TOKEN: ${{ github.token }}
- name: Commit stats
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add STATS.md
git add STATS.md packages/console/app/src/config.ts packages/stats/app/src/routes/stats-shell.tsx
git diff --staged --quiet || git commit -m "ignore: update download stats $(date -I)"
git push
env:
+35 -35
View File
@@ -31,7 +31,7 @@
},
"packages/ai": {
"name": "@opencode/ai",
"version": "2.0.3",
"version": "2.0.2",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode/schema": "workspace:*",
@@ -53,7 +53,7 @@
},
"packages/app": {
"name": "@opencode/app",
"version": "2.0.3",
"version": "2.0.2",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -112,7 +112,7 @@
},
"packages/cli": {
"name": "@opencode/cli",
"version": "2.0.3",
"version": "2.0.2",
"bin": {
"opencode2": "./bin/opencode2.cjs",
},
@@ -176,7 +176,7 @@
},
"packages/client": {
"name": "@opencode/client",
"version": "2.0.3",
"version": "2.0.2",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -202,7 +202,7 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
"version": "2.0.3",
"version": "2.0.2",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -216,7 +216,7 @@
},
"packages/console/app": {
"name": "@opencode/console-app",
"version": "2.0.3",
"version": "2.0.2",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -252,7 +252,7 @@
},
"packages/console/core": {
"name": "@opencode/console-core",
"version": "2.0.3",
"version": "2.0.2",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -279,7 +279,7 @@
},
"packages/console/function": {
"name": "@opencode/console-function",
"version": "2.0.3",
"version": "2.0.2",
"dependencies": {
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode/console-core": "workspace:*",
@@ -296,7 +296,7 @@
},
"packages/console/mail": {
"name": "@opencode/console-mail",
"version": "2.0.3",
"version": "2.0.2",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -320,7 +320,7 @@
},
"packages/console/support": {
"name": "@opencode/console-support",
"version": "2.0.3",
"version": "2.0.2",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode/console-core": "workspace:*",
@@ -340,7 +340,7 @@
},
"packages/core": {
"name": "@opencode/core",
"version": "2.0.3",
"version": "2.0.2",
"dependencies": {
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/anthropic": "3.0.82",
@@ -412,7 +412,7 @@
},
"packages/desktop": {
"name": "@opencode/desktop",
"version": "2.0.3",
"version": "2.0.2",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"electron-context-menu": "4.1.2",
@@ -464,7 +464,7 @@
},
"packages/enterprise": {
"name": "@opencode/enterprise",
"version": "2.0.3",
"version": "2.0.2",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/sdk": "1.18.21",
@@ -501,7 +501,7 @@
},
"packages/function": {
"name": "@opencode/function",
"version": "2.0.3",
"version": "2.0.2",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -517,7 +517,7 @@
},
"packages/http-recorder": {
"name": "@opencode/http-recorder",
"version": "2.0.3",
"version": "2.0.2",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.112",
},
@@ -536,7 +536,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode/httpapi-codegen",
"version": "2.0.3",
"version": "2.0.2",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -549,7 +549,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
"version": "2.0.3",
"version": "2.0.2",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -563,7 +563,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
"version": "2.0.3",
"version": "2.0.2",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -578,7 +578,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
"version": "2.0.3",
"version": "2.0.2",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode/ai": "workspace:*",
@@ -617,7 +617,7 @@
},
"packages/plugin-browser": {
"name": "@opencode/plugin-browser",
"version": "2.0.3",
"version": "2.0.2",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -647,7 +647,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
"version": "2.0.3",
"version": "2.0.2",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -662,7 +662,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
"version": "2.0.3",
"version": "2.0.2",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -686,7 +686,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
"version": "2.0.3",
"version": "2.0.2",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -707,7 +707,7 @@
},
"packages/server": {
"name": "@opencode/server",
"version": "2.0.3",
"version": "2.0.2",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
@@ -729,7 +729,7 @@
},
"packages/session-ui": {
"name": "@opencode/session-ui",
"version": "2.0.3",
"version": "2.0.2",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
@@ -764,7 +764,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
"version": "2.0.3",
"version": "2.0.2",
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/core": "workspace:*",
@@ -784,7 +784,7 @@
},
"packages/stats/app": {
"name": "@opencode/stats-app",
"version": "2.0.3",
"version": "2.0.2",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -818,7 +818,7 @@
},
"packages/stats/core": {
"name": "@opencode/stats-core",
"version": "2.0.3",
"version": "2.0.2",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -837,7 +837,7 @@
},
"packages/stats/server": {
"name": "@opencode/stats-server",
"version": "2.0.3",
"version": "2.0.2",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -883,7 +883,7 @@
},
"packages/theme": {
"name": "@opencode/theme",
"version": "2.0.3",
"version": "2.0.2",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -897,7 +897,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
"version": "2.0.3",
"version": "2.0.2",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -932,7 +932,7 @@
},
"packages/ui": {
"name": "@opencode/ui",
"version": "2.0.3",
"version": "2.0.2",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -967,7 +967,7 @@
},
"packages/util": {
"name": "@opencode/util",
"version": "2.0.3",
"version": "2.0.2",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -1000,7 +1000,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "2.0.3",
"version": "2.0.2",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1041,7 +1041,7 @@
},
"services/update": {
"name": "@opencode/update",
"version": "2.0.3",
"version": "2.0.2",
"dependencies": {
"jose": "6.0.11",
"semver": "catalog:",
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-euVUyj0CzjCA1nYbN2vKctEPzLkUlNGTK2dMNbackqM=",
"aarch64-linux": "sha256-qQkjqaxpjAae+rohoWI601QnrgKYghJ+ttqeiQBTwCM=",
"aarch64-darwin": "sha256-HYWs31TJlDZsDBNmbPARo16r7zNKy9x840uHGcUMYsk=",
"x86_64-darwin": "sha256-89FOrX813FENk3u8RAHCfyD7voaZWW++Z4Gpa3SkOJs="
"x86_64-linux": "sha256-QJn59dTbxspHl35vGxs6akynlfaWaPqvbMNsi0EqnbM=",
"aarch64-linux": "sha256-wa54uUIAth/ZOyejZ/TfU0zZdkhNirwq7yK1218jhBQ=",
"aarch64-darwin": "sha256-VvsfVdtxcVijCvlrWpBUkgmkYFoXX7QiXM0Fhz1t2VE=",
"x86_64-darwin": "sha256-IxYFBG5I+znXcTVqrAWKhG2qvVtjM+Amp68xIGKZogE="
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode",
"description": "AI-powered development tool",
"version": "2.0.3",
"version": "2.0.2",
"private": true,
"type": "module",
"packageManager": "bun@1.4.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.3",
"version": "2.0.2",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
+13 -24
View File
@@ -282,16 +282,12 @@ const removeEmptyToolInputKeys = (input: unknown): unknown => {
const lowerToolCall = (part: ToolCallPart): BedrockToolUseBlock => ({
toolUse: {
toolUseId: part.id,
// Models can emit names that Converse rejects when replayed in history.
name: part.name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64) || "_",
name: part.name,
input: removeEmptyToolInputKeys(part.input),
},
})
const lowerToolResultContent = Effect.fn("BedrockConverse.lowerToolResultContent")(function* (
part: ToolResultPart,
documentNames: Set<string>,
) {
const lowerToolResultContent = Effect.fn("BedrockConverse.lowerToolResultContent")(function* (part: ToolResultPart) {
if (part.result.type === "text" || part.result.type === "error")
return [{ text: ProviderShared.toolResultText(part) }]
if (part.result.type === "json") return [{ json: part.result.value }]
@@ -302,28 +298,22 @@ const lowerToolResultContent = Effect.fn("BedrockConverse.lowerToolResultContent
content.push({ text: item.text })
continue
}
const media = yield* BedrockMedia.lower(
{
type: "media",
mediaType: item.mime,
data: item.uri,
filename: item.name,
},
documentNames,
)
content.push(...media)
const media = yield* BedrockMedia.lower({
type: "media",
mediaType: item.mime,
data: item.uri,
filename: item.name,
})
content.push(media)
}
return content
})
const lowerToolResult = Effect.fn("BedrockConverse.lowerToolResult")(function* (
part: ToolResultPart,
documentNames: Set<string>,
) {
const lowerToolResult = Effect.fn("BedrockConverse.lowerToolResult")(function* (part: ToolResultPart) {
return {
toolResult: {
toolUseId: part.id,
content: yield* lowerToolResultContent(part, documentNames),
content: yield* lowerToolResultContent(part),
status: part.result.type === "error" ? "error" : "success",
},
} satisfies BedrockToolResultBlock
@@ -334,7 +324,6 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
breakpoints: BedrockCache.Breakpoints,
) {
const messages: BedrockMessage[] = []
const documentNames = new Set<string>()
const providerMetadataKey = request.model.route.providerMetadataKey ?? String(request.model.provider)
for (const message of request.messages) {
@@ -358,7 +347,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
continue
}
if (part.type === "media") {
content.push(...(yield* BedrockMedia.lower(part, documentNames)))
content.push(yield* BedrockMedia.lower(part))
continue
}
}
@@ -411,7 +400,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
for (const part of message.content) {
if (!ProviderShared.supportsContent(part, ["tool-result"]))
return yield* ProviderShared.unsupportedContent("Bedrock Converse", "tool", ["tool-result"])
content.push(yield* lowerToolResult(part, documentNames))
content.push(yield* lowerToolResult(part))
const cachePoint = BedrockCache.block(breakpoints, part.cache)
if (cachePoint) content.push(cachePoint)
}
@@ -57,25 +57,6 @@ const documentBlock = (name: string, format: DocumentFormat, bytes: string): Doc
},
})
function documentName(filename: string | undefined, names: Set<string>) {
const base =
(filename ?? "")
.replace(/\.[^.]*$/, "")
.replace(/[^a-zA-Z0-9 ()[\]-]/g, " ")
.replace(/\s+/g, " ")
.trim()
.slice(0, 200)
.trim() || "document"
let name = base
// Converse requires labels to be unique across the entire request, including tool results.
for (let index = 2; names.has(name); index++) {
const suffix = ` ${index}`
name = `${base.slice(0, 200 - suffix.length).trimEnd()}${suffix}`
}
names.add(name)
return name
}
const mediaBase64 = Effect.fn("BedrockMedia.mediaBase64")(function* (part: MediaPart) {
const media = ProviderShared.normalizeMedia(part)
const bytes = yield* Effect.fromResult(Encoding.decodeBase64(media.base64)).pipe(
@@ -91,26 +72,19 @@ const mediaBase64 = Effect.fn("BedrockMedia.mediaBase64")(function* (part: Media
// document block. Image MIME types not in `IMAGE_FORMATS` (e.g. `image/svg+xml`)
// get an image-specific error so the caller knows it's a format-support issue,
// not a kind-detection issue.
export const lower = Effect.fn("BedrockMedia.lower")(function* (part: MediaPart, documentNames: Set<string>) {
export const lower = Effect.fn("BedrockMedia.lower")(function* (part: MediaPart) {
const mime = part.mediaType.toLowerCase()
const imageFormat = IMAGE_FORMATS[mime as keyof typeof IMAGE_FORMATS]
if (imageFormat) {
return [{ image: { format: imageFormat, source: { bytes: yield* mediaBase64(part) } } } satisfies ImageBlock]
return { image: { format: imageFormat, source: { bytes: yield* mediaBase64(part) } } } satisfies ImageBlock
}
if (mime.startsWith("image/"))
return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support image media type ${part.mediaType}`)
const documentFormat = DOCUMENT_FORMATS[mime as keyof typeof DOCUMENT_FORMATS]
if (documentFormat) {
const name = documentName(part.filename, documentNames)
const block = documentBlock(name, documentFormat, yield* mediaBase64(part))
return part.filename !== undefined && part.filename !== name
? [
{
text: `Attached file ${ProviderShared.encodeJson(part.filename)} has document label ${ProviderShared.encodeJson(name)}.`,
},
block,
]
: [block]
if (!part.filename)
return yield* ProviderShared.invalidRequest("Bedrock Converse document media requires a filename")
return documentBlock(part.filename, documentFormat, yield* mediaBase64(part))
}
return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support media type ${part.mediaType}`)
})
@@ -1,7 +1,7 @@
import { EventStreamCodec } from "@smithy/eventstream-codec"
import { fromUtf8, toUtf8 } from "@smithy/util-utf8"
import { describe, expect } from "bun:test"
import { Effect, Encoding, Ref, Schema, Stream } from "effect"
import { Effect, Encoding, Ref, Stream } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import {
CacheHint,
@@ -11,11 +11,9 @@ import {
LLMEvent,
LLMRequest,
Message,
Tool,
ToolCallPart,
ToolChoice,
ToolDefinition,
ToolRuntime,
} from "../../src/index.js"
import { LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
@@ -396,45 +394,6 @@ describe("Bedrock Converse route", () => {
})
}),
)
;[
{ name: "browser.tabs.open", expected: "browser_tabs_open" },
{ name: "$lookup", expected: "_lookup" },
{ name: "", expected: "_" },
{ name: " ", expected: "___" },
{ name: "a".repeat(65), expected: "a".repeat(64) },
{ name: "lookup_123-ABC", expected: "lookup_123-ABC" },
{ name: "a".repeat(64), expected: "a".repeat(64) },
].forEach((item) => {
it.effect(`replays historical tool name ${JSON.stringify(item.name)} within Bedrock constraints`, () =>
Effect.gen(function* () {
const call = ToolCallPart.make({ id: "call_unknown", name: item.name, input: { query: "weather" } })
const error = `No tool named "${item.name}" is currently available. Please use a tool from the available tool list.`
const request = LLM.request({
model,
cache: "none",
tools: [ToolDefinition.make({ name: "execute", description: "Run code", inputSchema: { type: "object" } })],
messages: [
Message.user("Check the weather"),
Message.assistant([call]),
Message.tool({ id: call.id, name: call.name, result: error, resultType: "error" }),
Message.user("Say OK"),
],
})
const prepared = yield* compileRequest(request)
expect(prepared.body.messages[1].content).toEqual([
{ toolUse: { toolUseId: call.id, name: item.expected, input: call.input } },
])
expect(prepared.body.messages[2].content).toEqual([
{ toolResult: { toolUseId: call.id, content: [{ text: error }], status: "error" } },
{ text: "Say OK" },
])
expect(prepared.body.toolConfig.tools.map((tool) => tool.toolSpec.name)).toEqual(["execute"])
expect(request.messages[1].content[0]).toEqual(call)
expect(call.name).toBe(item.name)
}),
)
})
it.effect("removes empty keys recursively from outbound tool inputs without mutating history", () =>
Effect.gen(function* () {
@@ -838,57 +797,6 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("rejects a provider-emitted dotted name before normalizing its replay", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(baseRequest).pipe(
Effect.provide(
fixedBytes(
eventStreamBody(
[
"contentBlockStart",
{ contentBlockIndex: 0, start: { toolUse: { toolUseId: "call_unknown", name: "browser.tabs.open" } } },
],
["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: "{}" } } }],
["contentBlockStop", { contentBlockIndex: 0 }],
["messageStop", { stopReason: "tool_use" }],
),
),
),
)
const call = response.toolCalls[0]
if (!call) throw new Error("Expected a tool call")
expect(call.name).toBe("browser.tabs.open")
const dispatched = yield* ToolRuntime.dispatch(
{
browser_tabs_open: Tool.make({
description: "Open a tab",
parameters: Schema.Struct({}),
success: Schema.String,
execute: () => Effect.die("A normalized replay name must not select an executor"),
}),
},
call,
)
expect(dispatched.result).toEqual({
type: "error",
value:
'No tool named "browser.tabs.open" is currently available. Please use a tool from the available tool list.',
})
const prepared = yield* compileRequest(
LLM.request({
model,
cache: "none",
messages: [response.message, Message.tool({ id: call.id, name: call.name, result: dispatched.result })],
}),
)
expect(prepared.body.messages[0].content).toEqual([
{ toolUse: { toolUseId: call.id, name: "browser_tabs_open", input: {} } },
])
expect(response.toolCalls[0]?.name).toBe("browser.tabs.open")
}),
)
it.effect("ignores tool deltas without an open tool block", () =>
Effect.gen(function* () {
const body = eventStreamBody(
@@ -1765,141 +1673,29 @@ describe("Bedrock Converse route", () => {
role: "user",
content: [
{ text: "Summarize these documents." },
{ text: 'Attached file "report.pdf" has document label "report".' },
{ document: { format: "pdf", name: "report", source: { bytes: "UERGREFUQQ==" } } },
{ text: 'Attached file "data.csv" has document label "data".' },
{ document: { format: "csv", name: "data", source: { bytes: "Q1NWREFUQQ==" } } },
{ document: { format: "pdf", name: "report.pdf", source: { bytes: "UERGREFUQQ==" } } },
{ document: { format: "csv", name: "data.csv", source: { bytes: "Q1NWREFUQQ==" } } },
],
},
],
})
}),
)
;[
{
label: "filename punctuation",
filename: "report_v1.2?.pdf",
expected: "report v1 2",
duplicate: "report v1 2 2",
},
{
label: "repeated whitespace",
filename: " Quarterly\t \n report.txt",
expected: "Quarterly report",
duplicate: "Quarterly report 2",
},
{
label: "allowed characters",
filename: "Report - Final (v2) [2026]",
expected: "Report - Final (v2) [2026]",
duplicate: "Report - Final (v2) [2026] 2",
},
{ label: "accented filename", filename: "résumé.pdf", expected: "r sum", duplicate: "r sum 2" },
{ label: "non-Latin filename", filename: "報告書.pdf", expected: "document", duplicate: "document 2" },
{ label: "missing filename", filename: undefined, expected: "document", duplicate: "document 2" },
{ label: "empty filename", filename: "", expected: "document", duplicate: "document 2" },
{ label: "blank filename", filename: " \t\n", expected: "document", duplicate: "document 2" },
{ label: "extension-only filename", filename: ".pdf", expected: "document", duplicate: "document 2" },
{ label: "symbols-only filename", filename: "@@@.pdf", expected: "document", duplicate: "document 2" },
{
label: "overlong filename",
filename: `${"a".repeat(201)}.txt`,
expected: "a".repeat(200),
duplicate: `${"a".repeat(198)} 2`,
},
{
label: "maximum-length label",
filename: "a".repeat(200),
expected: "a".repeat(200),
duplicate: `${"a".repeat(198)} 2`,
},
{
label: "whitespace at truncation",
filename: `${"a".repeat(199)} b.txt`,
expected: "a".repeat(199),
duplicate: `${"a".repeat(198)} 2`,
},
].forEach((item) => {
it.effect(`normalizes ${item.label} in user and tool-result documents`, () =>
Effect.gen(function* () {
const request = LLM.request({
model,
cache: "none",
messages: [
Message.user([
{ type: "text", text: "Read this document" },
{ type: "media", mediaType: "application/pdf", data: "UERGREFUQQ==", filename: item.filename },
]),
Message.assistant([ToolCallPart.make({ id: "call_read", name: "read", input: {} })]),
Message.tool({
id: "call_read",
name: "read",
result: {
type: "content",
value: [
{ type: "text", text: "Read successfully" },
{
type: "file",
uri: "data:application/pdf;base64,UERGREFUQQ==",
mime: "application/pdf",
name: item.filename,
},
],
},
}),
],
})
const original = JSON.stringify(request.messages)
const first = yield* compileRequest(request)
const second = yield* compileRequest(request)
const expected = { format: "pdf", name: item.expected, source: { bytes: "UERGREFUQQ==" } }
expect(first.body.messages[0].content.find((part) => "document" in part)?.document).toEqual(expected)
expect(
first.body.messages[2].content[0].toolResult.content.find((part) => "document" in part)?.document,
).toEqual({
...expected,
name: item.duplicate,
})
expect(second.body).toEqual(first.body)
expect(JSON.stringify(request.messages)).toBe(original)
}),
)
})
it.effect("keeps colliding document labels distinct within a request", () =>
it.effect("requires names for document media", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const error = yield* compileRequest(
LLM.request({
model,
cache: "none",
messages: [
Message.user([
{ type: "text", text: "Read these documents" },
...["report_v1.txt", "report#v1.txt", "report v1 2.txt", "report v1.txt"].map((filename) => ({
type: "media" as const,
mediaType: "text/plain",
data: "SGVsbG8=",
filename,
})),
]),
],
messages: [Message.user({ type: "media", mediaType: "application/pdf", data: "UERGREFUQQ==" })],
}),
)
expect(prepared.body.messages[0].content.slice(1)).toEqual([
{ text: 'Attached file "report_v1.txt" has document label "report v1".' },
{ document: { format: "txt", name: "report v1", source: { bytes: "SGVsbG8=" } } },
{ text: 'Attached file "report#v1.txt" has document label "report v1 2".' },
{ document: { format: "txt", name: "report v1 2", source: { bytes: "SGVsbG8=" } } },
{ text: 'Attached file "report v1 2.txt" has document label "report v1 2 2".' },
{ document: { format: "txt", name: "report v1 2 2", source: { bytes: "SGVsbG8=" } } },
{ text: 'Attached file "report v1.txt" has document label "report v1 3".' },
{ document: { format: "txt", name: "report v1 3", source: { bytes: "SGVsbG8=" } } },
])
).pipe(Effect.flip)
expect(error.message).toContain("document media requires a filename")
}),
)
it.effect("annotates renamed document-only messages with their original filename", () =>
it.effect("passes named document-only messages through for provider validation", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
@@ -1919,44 +1715,12 @@ describe("Bedrock Converse route", () => {
expect(prepared.body.messages).toEqual([
{
role: "user",
content: [
{ text: 'Attached file "report.pdf" has document label "report".' },
{ document: { format: "pdf", name: "report", source: { bytes: "UERGREFUQQ==" } } },
],
content: [{ document: { format: "pdf", name: "report.pdf", source: { bytes: "UERGREFUQQ==" } } }],
},
])
}),
)
it.effect("quotes original filenames in annotations and omits redundant mappings", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
cache: "none",
messages: [
Message.user([
{ type: "text", text: "Read these documents" },
...["report", undefined, 'report "final"\n.pdf'].map((filename) => ({
type: "media" as const,
mediaType: "text/plain",
data: "SGVsbG8=",
filename,
})),
]),
],
}),
)
expect(prepared.body.messages[0].content).toEqual([
{ text: "Read these documents" },
{ document: { format: "txt", name: "report", source: { bytes: "SGVsbG8=" } } },
{ document: { format: "txt", name: "document", source: { bytes: "SGVsbG8=" } } },
{ text: 'Attached file "report \\"final\\"\\n.pdf" has document label "report final".' },
{ document: { format: "txt", name: "report final", source: { bytes: "SGVsbG8=" } } },
])
}),
)
it.effect("lowers document media in tool results", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -1976,7 +1740,7 @@ describe("Bedrock Converse route", () => {
type: "file",
uri: "data:application/pdf;base64,UERGREFUQQ==",
mime: "application/pdf",
name: "report.pdf",
name: "report",
},
],
},
@@ -1999,7 +1763,6 @@ describe("Bedrock Converse route", () => {
status: "success",
content: [
{ text: "Read successfully" },
{ text: 'Attached file "report.pdf" has document label "report".' },
{ document: { format: "pdf", name: "report", source: { bytes: "UERGREFUQQ==" } } },
],
},
File diff suppressed because it is too large Load Diff
@@ -248,7 +248,7 @@ for (const direction of ["ltr", "rtl"]) {
expect(messageAfter).toEqual(messageBefore)
await page.locator('[data-component="composer-editor"]').pressSequentially("Also: ")
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(`Also: ${followUp}`)
await expect.poll(() => mock.calls).toEqual(["worktree", "session", "prompt"])
expect(mock.calls).toEqual(["worktree", "session", "prompt"])
})
}
@@ -55,8 +55,6 @@ for (const direction of ["ltr", "rtl"] as const) {
"href",
`#opencode-v2-icon-${workspace ? "outline-worktree" : "monitor"}`,
)
// Initial layout scrolls this sticky header's ancestor and dismisses its tooltip.
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
const background = await trigger.evaluate((element) => getComputedStyle(element).backgroundColor)
await trigger.hover()
await expect(trigger).not.toHaveCSS("background-color", background)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "2.0.3",
"version": "2.0.2",
"description": "",
"type": "module",
"exports": {
+15 -108
View File
@@ -103,7 +103,7 @@ export function createTimelineVirtualizer(input: Input) {
{ defer: true },
),
)
const [rendering, setRendering] = createStore({ initialTail: coldBottomMount, scrollAdjustment: 0 })
const [rendering, setRendering] = createStore({ initialTail: coldBottomMount })
const rows = input.projection.rows
const rowByKey = input.projection.rowByKey
const rowKeys = createMemo(() => rows().map(TimelineRow.key), undefined, {
@@ -154,10 +154,6 @@ export function createTimelineVirtualizer(input: Input) {
})
const measuredElements = new WeakSet<Element>()
let touchStart: number | undefined
let touchTarget: EventTarget | null = null
let touchNested = false
let touchScrolling = false
let touchAdjustment = 0
let pointerHeld = false
let maxScroll = 0
let virtualContent: HTMLDivElement | undefined
@@ -181,23 +177,7 @@ export function createTimelineVirtualizer(input: Input) {
observeElementOffset: (instance, callback) => {
reportOffset = (offset, scrolling) => {
if (!active()) return
// Rows and the sizer use the opposite translation while native touch
// scrolling keeps its own offset. Range selection uses the logical offset.
batch(() => {
const logicalOffset = offset + rendering.scrollAdjustment
callback(rendering.scrollAdjustment ? Math.max(0, logicalOffset) : offset, scrolling)
// Reconcile both start boundaries in one native write. Gradually
// clamping row translations lets the compositor paint between
// corrections and makes the content oscillate at the top.
const root = listRoot()
if (
rendering.scrollAdjustment !== 0 &&
root &&
(logicalOffset <= 0 || offset <= 0 || (touchStart !== undefined && offset <= root.clientHeight))
)
flushTouchAdjustment()
if (!scrolling && touchStart === undefined) finishTouchScroll()
})
callback(offset, scrolling)
settleColdBottom()
}
return observeElementOffsetReconnectAware(instance, reportOffset, () => {
@@ -232,7 +212,6 @@ export function createTimelineVirtualizer(input: Input) {
scrollToFn: (offset, options, instance) => {
if (!active()) return
if (batchingColdSizes && input.pinned()) return
setRendering("scrollAdjustment", 0)
if (virtualContent) virtualContent.style.height = `${instance.getTotalSize()}px`
elementScroll(offset, options, instance)
},
@@ -284,15 +263,7 @@ export function createTimelineVirtualizer(input: Input) {
batch(() => {
sizes.forEach(([index, value]) => {
const row = rows()[index]
if (!row || TimelineRow.key(row) !== value.key) return
resizeItem(index, value.size)
// TanStack recalculates its range after each resize. Advance the
// logical fold before deciding whether the next row needs anchoring.
if (!touchAdjustment) return
setRendering("scrollAdjustment", (value) => value + touchAdjustment)
touchAdjustment = 0
const root = listRoot()
if (root) reportOffset?.(root.scrollTop, virtualizer.isScrolling)
if (row && TimelineRow.key(row) === value.key) resizeItem(index, value.size)
})
})
batchingColdSizes = false
@@ -307,40 +278,13 @@ export function createTimelineVirtualizer(input: Input) {
})
}
onCleanup(() => pendingSizes.clear())
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, delta, instance) => {
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => {
// Prepended rows can resize more than once as deferred content mounts. Keep
// compensating while they remain entirely above the visible content fold.
if (addedKeys.has(String(item.key)))
return item.end <= (instance.scrollOffset ?? 0) + instance.scrollAdjustments + instance.options.scrollMargin
const first = instance.range?.startIndex
const adjust = addedKeys.has(String(item.key))
? item.end <= (instance.scrollOffset ?? 0) + instance.scrollAdjustments + instance.options.scrollMargin
: first !== undefined && item.index < first
if (!touchScrolling || input.pinned()) return adjust
// iOS defers native scroll writes until momentum ends. Keep the same visual
// anchor now, rather than moving rows now and snapping the viewport back later.
if (adjust) touchAdjustment += delta
return false
}
function finishTouchScroll() {
touchScrolling = false
flushTouchAdjustment()
}
function prepareNavigation() {
if (touchStart === undefined) touchScrolling = false
flushTouchAdjustment()
}
function flushTouchAdjustment() {
const adjustment = rendering.scrollAdjustment
const root = listRoot()
if (!adjustment || !root) return
// Transfer the translation into the native offset in the same paint.
batch(() => {
setRendering("scrollAdjustment", 0)
if (virtualContent) virtualContent.style.height = `${virtualizer.getTotalSize()}px`
elementScroll(Math.max(0, root.scrollTop + adjustment), {}, virtualizer)
})
return first !== undefined && item.index < first
}
const virtualItemByKey = createMemo(
() => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)),
@@ -370,12 +314,10 @@ export function createTimelineVirtualizer(input: Input) {
: -1
const index = partIndex >= 0 ? partIndex : input.projection.messageRowIndex().get(id)
if (index === undefined) return
prepareNavigation()
virtualizer.scrollToIndex(index, { align: "center" })
})
input.setScrollToEnd?.(() => {
if (!active() || !listRoot()?.isConnected) return
prepareNavigation()
input.onPin()
virtualizer.scrollToEnd()
})
@@ -480,53 +422,19 @@ export function createTimelineVirtualizer(input: Input) {
}
const handleListTouchStart = (event: TouchEvent) => {
clearTouchTarget()
input.onUserScroll(event.target)
touchScrolling = true
touchStart = event.touches[0]?.clientY
const root = listRoot()
const nested = event.target instanceof Element ? event.target.closest<HTMLElement>("[data-scrollable]") : null
touchNested = !!nested && nested !== root && nested.scrollHeight > nested.clientHeight
// Native touch events keep their original target, even when streaming or
// virtualization detaches it. Listen there instead of relying on bubbling.
touchTarget = event.target
touchTarget?.addEventListener("touchmove", handleListTouchMove, { passive: true })
touchTarget?.addEventListener("touchend", handleListTouchEnd, { passive: true })
touchTarget?.addEventListener("touchcancel", handleListTouchEnd, { passive: true })
if (root) reportOffset?.(root.scrollTop, virtualizer.isScrolling)
}
const handleListTouchMove = (event: Event) => {
if (!(event instanceof TouchEvent)) return
const handleListTouchMove = (event: TouchEvent & { currentTarget: HTMLDivElement }) => {
const current = event.touches[0]?.clientY
if (current === undefined || touchStart === undefined) return
const previous = touchStart
touchStart = current
// A retained target can outlive its whole session view. Only the active
// timeline may change the shared follow state; release still cleans up below.
if (!active()) return
// Dragging the content downward reveals earlier messages.
if (current <= previous) return
// A nested scrollport owns the intent. If it chains into the timeline at a
// boundary, the resulting native timeline scroll below will unpin instead.
if (touchNested) return
if (current <= touchStart) return
touchStart = current
input.onUnpin()
}
const handleListTouchEnd = () => {
clearTouchTarget()
touchStart = undefined
if (!virtualizer.isScrolling) finishTouchScroll()
}
function clearTouchTarget() {
touchTarget?.removeEventListener("touchmove", handleListTouchMove)
touchTarget?.removeEventListener("touchend", handleListTouchEnd)
touchTarget?.removeEventListener("touchcancel", handleListTouchEnd)
touchTarget = null
}
onCleanup(clearTouchTarget)
// Drag-selecting past the edge and dragging the scrollbar both scroll without a wheel or key,
// so a held pointer is what separates those from the virtualizer's own measurement adjustments.
const handleListPointerDown = (event: PointerEvent & { currentTarget: HTMLDivElement }) => {
@@ -567,7 +475,7 @@ export function createTimelineVirtualizer(input: Input) {
const atEnd = maxScroll - scrollTop <= endEpsilon
const arrived = scrollTop > previousTop + endEpsilon || maxScroll < previousMaxScroll
if (maxScroll <= 1 || (atEnd && arrived)) input.onPin()
else if ((pointerHeld || touchScrolling) && scrollTop < previousTop - endEpsilon) input.onUnpin()
else if (pointerHeld && scrollTop < previousTop - endEpsilon) input.onUnpin()
settleColdBottom()
input.onScheduleScrollState(root)
input.onHistoryScroll()
@@ -596,7 +504,7 @@ export function createTimelineVirtualizer(input: Input) {
data-timeline-key={rowProps.rowKey}
style={{
position: "absolute",
top: `${item().start - topOffset() - rendering.scrollAdjustment}px`,
top: `${item().start - topOffset()}px`,
left: "0",
width: "100%",
height: `${item().size}px`,
@@ -674,10 +582,9 @@ export function createTimelineVirtualizer(input: Input) {
<ScrollView
data-slot="session-timeline-scroll"
viewportRef={bindListRoot}
onBeforeScroll={prepareNavigation}
verticalScrollAdjustment={rendering.scrollAdjustment}
onWheel={handleListWheel}
onTouchStart={handleListTouchStart}
onTouchMove={handleListTouchMove}
onPointerDown={handleListPointerDown}
onKeyDown={handleListKeyDown}
onScroll={handleListScroll}
@@ -695,7 +602,7 @@ export function createTimelineVirtualizer(input: Input) {
if (active()) input.setContentRef(element)
}}
style={{
height: `${virtualizer.getTotalSize() - rendering.scrollAdjustment}px`,
height: `${virtualizer.getTotalSize()}px`,
position: "relative",
width: "100%",
visibility: coldBottomMount ? "hidden" : undefined,
@@ -705,7 +612,7 @@ export function createTimelineVirtualizer(input: Input) {
<div
data-timeline-row="bottom-spacer"
class="h-16 absolute top-0 left-0 w-full"
style={{ transform: `translateY(${virtualizer.getTotalSize() - 64 - rendering.scrollAdjustment}px)` }}
style={{ transform: `translateY(${virtualizer.getTotalSize() - 64}px)` }}
>
{props.bottomSpacer}
</div>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/cli",
"version": "2.0.3",
"version": "2.0.2",
"type": "module",
"license": "MIT",
"bin": {
+36 -14
View File
@@ -1,7 +1,7 @@
/** @jsxImportSource @opentui/solid */
// Split-footer status shown while a freshly launched CLI replaces a
// version-mismatched background service before the TUI attaches.
import { createCliRenderer, RGBA, TextAttributes, type CliRenderer } from "@opentui/core"
import { createCliRenderer, RGBA, TextAttributes, type CliRenderer, type ThemeMode } from "@opentui/core"
import { render, useTerminalDimensions } from "@opentui/solid"
import { OPENCODE_VERSION } from "../version"
import { registerOpencodeSpinner } from "@opencode/tui/component/register-spinner"
@@ -30,11 +30,17 @@ const completionHold = 650
export type Handle = {
readonly begin: (from?: string) => boolean
readonly loading: () => void
readonly finish: () => Promise<undefined>
readonly finish: () => Promise<Handoff | undefined>
readonly fail: (message: string) => Promise<void>
readonly close: () => Promise<void>
}
export type Handoff = {
readonly renderer: CliRenderer
readonly mode: ThemeMode | null
readonly complete: () => void
}
export const make = (): Handle => {
let session: Promise<Session | undefined> | undefined
return {
@@ -66,7 +72,7 @@ export const make = (): Handle => {
type Session = {
readonly loading: () => Promise<void>
readonly finish: () => Promise<undefined>
readonly finish: () => Promise<Handoff>
readonly fail: (message: string) => Promise<void>
readonly close: () => Promise<void>
}
@@ -77,6 +83,7 @@ async function open(from?: string): Promise<Session> {
const [outcome, setOutcome] = createSignal<"running" | "success" | "failure">("running")
const [failure, setFailure] = createSignal("")
const [animating, setAnimating] = createSignal(true)
const [visible, setVisible] = createSignal(true)
let resolveOutcome: (() => void) | undefined
const renderer = await createCliRenderer({
stdin: process.stdin,
@@ -94,17 +101,20 @@ async function open(from?: string): Promise<Session> {
externalOutputMode: "capture-stdout",
consoleMode: "disabled",
})
const terminalMode = renderer.waitForThemeMode(1000).catch(() => null)
await render(
() => (
<UpdateFooter
from={from}
active={active}
outcome={outcome}
failure={failure}
animating={animating}
renderer={renderer}
onOutcomeSettled={() => resolveOutcome?.()}
/>
<Show when={visible()}>
<UpdateFooter
from={from}
active={active}
outcome={outcome}
failure={failure}
animating={animating}
renderer={renderer}
onOutcomeSettled={() => resolveOutcome?.()}
/>
</Show>
),
renderer,
).catch((error) => {
@@ -138,8 +148,10 @@ async function open(from?: string): Promise<Session> {
if (completed) await setTimeout(hold)
}
let closing: Promise<void> | undefined
let transferred = false
const close = () =>
(closing ??= (async () => {
if (transferred) return
setAnimating(false)
if (renderer.isDestroyed) return
renderer.pause()
@@ -162,8 +174,18 @@ async function open(from?: string): Promise<Session> {
await waitForStage()
await transitionTo("success", completionHold)
})
await close()
return undefined
const mode = await terminalMode
renderer.externalOutputMode = "passthrough"
renderer.screenMode = "alternate-screen"
renderer.consoleMode = "console-overlay"
renderer.requestRender()
await Promise.race([renderer.idle(), setTimeout(500)])
transferred = true
return {
renderer,
mode,
complete: () => setVisible(false),
}
},
fail: (message) =>
settle(async () => {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/client",
"version": "2.0.3",
"version": "2.0.2",
"type": "module",
"license": "MIT",
"repository": {
+1 -11
View File
@@ -18,7 +18,6 @@ import type { PromptInput } from "@opencode/schema/prompt-input"
import type { AgentAttachment } from "@opencode/schema/prompt"
import type { Skill } from "@opencode/schema/skill"
import type { Event } from "@opencode/schema/event"
import type { FileDiff } from "@opencode/schema/file-diff"
import type { InstructionEntry } from "@opencode/schema/instruction-entry"
import type { Schema } from "effect"
import type { EventLog } from "@opencode/schema/event-log"
@@ -37,6 +36,7 @@ import type { PtyTicket } from "@opencode/schema/pty-ticket"
import type { Reference } from "@opencode/schema/reference"
import type { Worktree } from "@opencode/schema/worktree"
import type { Vcs } from "@opencode/schema/vcs"
import type { FileDiff } from "@opencode/schema/file-diff"
import type { WebSearch } from "@opencode/schema/websearch"
import type { Config } from "@opencode/schema/config"
@@ -361,15 +361,6 @@ export type SessionContextInput = { readonly sessionID: Session.ID }
export type SessionContextOutput = ReadonlyArray<SessionMessage.Info>
export type SessionContextOperation<E = never> = (input: SessionContextInput) => Effect.Effect<SessionContextOutput, E>
export type SessionDiffInput = {
readonly sessionID: Session.ID
readonly from?: SessionMessage.ID | undefined
readonly to?: SessionMessage.ID | undefined
readonly context?: number | undefined
}
export type SessionDiffOutput = ReadonlyArray<FileDiff.Info>
export type SessionDiffOperation<E = never> = (input: SessionDiffInput) => Effect.Effect<SessionDiffOutput, E>
export type SessionInboxListInput = { readonly sessionID: Session.ID }
export type SessionInboxListOutput = ReadonlyArray<SessionInbox.Info>
export type SessionInboxListOperation<E = never> = (
@@ -1159,7 +1150,6 @@ export interface SessionApi<E = never> {
readonly commit: SessionRevertCommitOperation<E>
}
readonly context: SessionContextOperation<E>
readonly diff: SessionDiffOperation<E>
readonly inbox: {
readonly list: SessionInboxListOperation<E>
readonly cancel: SessionInboxCancelOperation<E>
@@ -68,8 +68,6 @@ import type {
SessionRevertCommitOutput,
SessionContextInput,
SessionContextOutput,
SessionDiffInput,
SessionDiffOutput,
SessionInboxListInput,
SessionInboxListOutput,
SessionInboxCancelInput,
@@ -601,17 +599,6 @@ const EndpointSessionContext = (raw: RawClient["server.session"]) => (input: Ses
),
)
const EndpointSessionDiff = (raw: RawClient["server.session"]) => (input: SessionDiffInput) =>
preserveEffect<SessionDiffOutput>()(
raw["session.diff"]({
params: { sessionID: input["sessionID"] },
query: { from: input["from"], to: input["to"], context: input["context"] },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointSessionInboxList = (raw: RawClient["server.session"]) => (input: SessionInboxListInput) =>
preserveEffect<SessionInboxListOutput>()(
raw["session.inbox.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
@@ -751,7 +738,6 @@ const adaptGroupSession = (raw: RawClient["server.session"]) => ({
commit: EndpointSessionRevertCommit(raw),
},
context: EndpointSessionContext(raw),
diff: EndpointSessionDiff(raw),
inbox: {
list: EndpointSessionInboxList(raw),
cancel: EndpointSessionInboxCancel(raw),
@@ -62,8 +62,6 @@ import type {
SessionRevertCommitOutput,
SessionContextInput,
SessionContextOutput,
SessionDiffInput,
SessionDiffOutput,
SessionInboxListInput,
SessionInboxListOutput,
SessionInboxCancelInput,
@@ -851,18 +849,6 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
diff: (input: SessionDiffInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionDiffOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/diff`,
query: { from: input["from"], to: input["to"], context: input["context"] },
successStatus: 200,
declaredStatuses: [400, 401, 404, 500],
empty: false,
},
requestOptions,
).then((value) => value.data),
inbox: {
list: (input: SessionInboxListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionInboxListOutput }>(
@@ -147,14 +147,6 @@ export type SessionProviderContextProvenance = {
endpoint: string
}
export type SessionMessageIdle = {
id: string
metadata?: { [x: string]: JsonValue }
time: { created: number }
type: "idle"
outcome: "succeeded" | "failed" | "interrupted"
}
export type SessionActive = { type: "running" }
export type SessionInboxDelivery = "steer" | "queue"
@@ -2218,7 +2210,6 @@ export type SessionMessageInfo =
| SessionMessageShell
| SessionMessageAssistant
| SessionMessageCompaction
| SessionMessageIdle
export type SessionMessageContentUpdated = {
id: string
@@ -3231,13 +3222,6 @@ export type SessionImportInput = {
}
}
)
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly type: "idle"
readonly outcome: "succeeded" | "failed" | "interrupted"
}
>
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["info"]
@@ -3548,13 +3532,6 @@ export type SessionImportInput = {
}
}
)
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly type: "idle"
readonly outcome: "succeeded" | "failed" | "interrupted"
}
>
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["messages"]
@@ -3865,13 +3842,6 @@ export type SessionImportInput = {
}
}
)
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly type: "idle"
readonly outcome: "succeeded" | "failed" | "interrupted"
}
>
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["location"]
@@ -4361,27 +4331,6 @@ export type SessionContextInput = { readonly sessionID: { readonly sessionID: st
export type SessionContextOutput = { data: Array<SessionMessageInfo> }["data"]
export type SessionDiffInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly from?: {
readonly from?: string | undefined
readonly to?: string | undefined
readonly context?: number | undefined
}["from"]
readonly to?: {
readonly from?: string | undefined
readonly to?: string | undefined
readonly context?: number | undefined
}["to"]
readonly context?: {
readonly from?: string | undefined
readonly to?: string | undefined
readonly context?: number | undefined
}["context"]
}
export type SessionDiffOutput = { data: Array<FileDiffInfo> }["data"]
export type SessionInboxListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionInboxListOutput = { data: Array<SessionInboxInfo> }["data"]
-12
View File
@@ -1028,18 +1028,6 @@ export function createData(config: CreateDataInput) {
if (currentAssistant) currentAssistant.retry = undefined
})
if (event.type === "session.execution.interrupted" && event.data.reason === "shutdown") return
// Mirror the projected idle marker so turn boundaries match before the next message read.
message.insert(event.data.sessionID, {
id: messageIDFromEvent(event.id),
type: "idle",
outcome:
event.type === "session.execution.succeeded"
? "succeeded"
: event.type === "session.execution.failed"
? "failed"
: "interrupted",
time: { created: event.created },
})
// An event can overtake the first read; queue a revalidation when that read is still active.
if (!store.session.info[event.data.sessionID] && !sync.has(`session:${event.data.sessionID}`)) return
result.session.invalidate(event.data.sessionID)
-6
View File
@@ -440,9 +440,3 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] Failures raised by the interpreter are `TypeError`s unless JavaScript names them otherwise (`RangeError`,
`ReferenceError`, `SyntaxError`, `URIError`), so `e instanceof TypeError` and `e.constructor === TypeError`
hold. Unsupported syntax reached at runtime is a `SyntaxError`; awaited tool failures stay plain `Error`.
Host errors escaping a built-in (`(1).toFixed(200)`) become the same-named program error at the call.
- [x] One failure is one error object: every `catch`, rejection handler, and `allSettled` reason for the same
failure sees the identical value, so `a === b` holds after awaiting the same rejected promise twice.
- [x] Rethrowing an interpreter failure keeps its diagnostic: `catch (e) { throw e }` still reports the original
kind and source location. An uncaught program `Error` reports as `name: message` (`TypeError: bad input`);
other thrown values report as `Uncaught: <value>`.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/codemode",
"version": "2.0.3",
"version": "2.0.2",
"description": "Effect-native confined code execution over schema-described tools",
"type": "module",
"license": "MIT",
+28 -46
View File
@@ -2,7 +2,7 @@ import { Effect } from "effect"
import type { Diagnostic } from "../codemode.js"
import { ToolError } from "../tool-error.js"
import { toData, ToolRuntimeError } from "../data.js"
import { type AstNode, formatLocation, PendingThrow, ProgramThrow, sourceLocation, typeError } from "./model.js"
import { type AstNode, formatLocation, InterpreterRuntimeError, ProgramThrow, sourceLocation } from "./model.js"
import { containsRuntimeReference } from "./references.js"
import { createErrorValue, type ErrorType, isErrorType } from "./intrinsics.js"
import { constructor, methods, prototypeFrom, receiver } from "./native.js"
@@ -20,7 +20,7 @@ import { type Runner } from "./runner.js"
import { coerceToString } from "../stdlib/value.js"
export const normalizeError = (error: unknown): Diagnostic => {
if (error instanceof PendingThrow) {
if (error instanceof InterpreterRuntimeError) {
return {
kind: error.kind,
message: `${error.message}${formatLocation(error.node)}`,
@@ -43,15 +43,14 @@ export const normalizeError = (error: unknown): Diagnostic => {
if (error instanceof ProgramThrow) {
const value = error.value
if (value instanceof ProgramError) {
return value.host ? normalizeError(value.host) : { kind: "ExecutionFailure", message: errorToString(value) }
}
let message: string
if (containsRuntimeReference(value)) {
// Never expose runtime reference internals through thrown values.
message = "a non-data value"
} else if (typeof value === "string") {
message = value
} else if (value instanceof ProgramObject && typeof get(value, "message") === "string") {
message = get(value, "message") as string
} else {
try {
message = JSON.stringify(toData(value, "Thrown value")) ?? String(value)
@@ -82,47 +81,14 @@ export const normalizeError = (error: unknown): Diagnostic => {
}
}
/**
* Gives a failure the source location of the expression that raised it, keeping the first one attached. Host errors
* that escape a built-in become the equivalent program error here.
*/
export const locate = (error: unknown, node?: AstNode): unknown => {
if (error instanceof PendingThrow) {
if (error.node === undefined && node) error.node = node
return error
}
if (error instanceof Error && !(error instanceof ToolError) && !(error instanceof ToolRuntimeError)) {
return new PendingThrow(isErrorType(error.name) ? error.name : "Error", error.message, node)
}
return error
}
/** The program value a handler receives for a failure; one failure always yields the same value. */
export const materialize = <R>(runner: Runner<R>, thrown: unknown): unknown => {
export const caughtErrorValue = <R>(runner: Runner<R>, thrown: unknown): unknown => {
if (thrown instanceof ProgramThrow) return thrown.value
const prototypes = runner.prototypes
if (thrown instanceof PendingThrow) {
if (thrown.value === undefined) {
thrown.value = createErrorValue(prototypes[thrown.type], thrown.message)
thrown.value.host = thrown
}
return thrown.value
}
if (thrown instanceof InterpreterRuntimeError) return createErrorValue(prototypes[thrown.type], thrown.message)
const type = thrown instanceof Error && isErrorType(thrown.name) ? thrown.name : "Error"
return createErrorValue(prototypes[type], normalizeError(thrown).message)
}
/** Error.prototype.toString: `name: message`, omitting whichever side is empty. */
const errorToString = (self: ProgramObject): string => {
const name = get(self, "name")
const message = get(self, "message")
const shownName = name === undefined ? "Error" : coerceToString(name)
const shownMessage = message === undefined ? "" : coerceToString(message)
if (shownMessage === "") return shownName
if (shownName === "") return shownMessage
return `${shownName}: ${shownMessage}`
}
export const createAggregateErrorValue = <R>(
runner: Runner<R>,
errors: Array<unknown>,
@@ -138,10 +104,13 @@ const constructAggregateErrorValue = <R>(
runner: Runner<R>,
args: Array<unknown>,
proto: ProgramObject,
node: AstNode,
): Effect.Effect<ProgramError, unknown, R> =>
Effect.gen(function* () {
const cursor = yield* runner.syncIterator(args[0])
if (cursor === undefined) throw typeError("new AggregateError(...) expects a synchronous iterable of errors.")
const cursor = yield* runner.syncIterator(args[0], node)
if (cursor === undefined) {
throw new InterpreterRuntimeError("new AggregateError(...) expects a synchronous iterable of errors.", node)
}
const errors: Array<unknown> = []
while (true) {
const step = yield* cursor.next
@@ -156,21 +125,34 @@ const constructAggregateErrorValue = <R>(
export const errorGlobal = <R>(type: ErrorType, runner: Runner<R>) => {
const protos = runner.prototypes
const prototype = protos[type]
const construct = (args: Array<unknown>, newTarget: Callable) => {
const construct = (args: Array<unknown>, newTarget: Callable, node: AstNode) => {
const proto = prototypeFrom(newTarget, prototype)
return type === "AggregateError"
? constructAggregateErrorValue(runner, args, proto)
? constructAggregateErrorValue(runner, args, proto, node)
: Effect.sync(() => createErrorValue(proto, args[0] === undefined ? undefined : coerceToString(args[0])))
}
const ctor: NativeFunction<R> = constructor<R>(protos, prototype, {
name: type,
length: type === "AggregateError" ? 2 : 1,
call: (_, args) => construct(args, ctor),
call: (_, args, node) => construct(args, ctor, node),
construct,
})
if (type === "Error") {
methods(protos, prototype, [
["toString", 0, (thisValue) => errorToString(receiver(ProgramObject, thisValue, "Error.prototype.toString"))],
[
"toString",
0,
(thisValue, _, node) => {
const self = receiver(ProgramObject, thisValue, "Error.prototype.toString", node)
const name = get(self, "name")
const message = get(self, "message")
const shownName = name === undefined ? "Error" : coerceToString(name)
const shownMessage = message === undefined ? "" : coerceToString(message)
if (shownMessage === "") return shownName
if (shownName === "") return shownMessage
return `${shownName}: ${shownMessage}`
},
],
])
}
return ctor
+2 -2
View File
@@ -9,7 +9,7 @@ import { ToolRuntime } from "../tool-runtime.js"
import { normalizeError } from "./errors.js"
import { createPrototypes } from "./intrinsics.js"
import type { Host } from "./globals.js"
import { PendingThrow } from "./model.js"
import { InterpreterRuntimeError } from "./model.js"
import { PromiseRuntime } from "./promises.js"
import { Runtime } from "./runtime.js"
@@ -122,7 +122,7 @@ const parseProgram = (code: string): Program => {
const transpiled = transpile(`async function __codemode__() {\n${code}\n}`)
if (transpiled.error !== undefined) {
throw new PendingThrow("SyntaxError", `Failed to parse TypeScript: ${transpiled.error}`, undefined, "ParseError")
throw new InterpreterRuntimeError(`Failed to parse TypeScript: ${transpiled.error}`, undefined, "ParseError")
}
const bodyStart = transpiled.outputText.indexOf("{") + 1
@@ -1,6 +1,6 @@
import { Effect } from "effect"
import { fn, type Method, methods, receiver } from "./native.js"
import { AsyncIteratorSymbol, type GeneratorRequestKind, IteratorSymbol } from "./model.js"
import { type AstNode, AsyncIteratorSymbol, type GeneratorRequestKind, IteratorSymbol } from "./model.js"
import { define, hidden, ProgramGenerator } from "./objects.js"
import type { PromiseRuntime } from "./promises.js"
import type { Runner } from "./runner.js"
@@ -14,9 +14,9 @@ export const generatorGlobals = <R>(runner: Runner<R>, promises: PromiseRuntime<
const request = (kind: GeneratorRequestKind): Method => [
kind,
1,
(thisValue: unknown, args: Array<unknown>) => {
const generator = receiver(ProgramGenerator, thisValue, `${label}.prototype.${kind}`)
const requested = generator.request(kind, args[0]) as Effect.Effect<unknown, unknown, R>
(thisValue: unknown, args: Array<unknown>, node: AstNode) => {
const generator = receiver(ProgramGenerator, thisValue, `${label}.prototype.${kind}`, node)
const requested = generator.request(kind, args[0], node) as Effect.Effect<unknown, unknown, R>
return generator.asynchronous ? promises.create(requested) : requested
},
]
+9 -6
View File
@@ -16,7 +16,7 @@ import { ToolReference } from "../tool-runtime.js"
import { errorGlobal } from "./errors.js"
import { errorTypes } from "./intrinsics.js"
import { constants, constructor, native } from "./native.js"
import { AsyncIteratorSymbol, IteratorSymbol, typeError } from "./model.js"
import { type AstNode, AsyncIteratorSymbol, InterpreterRuntimeError, IteratorSymbol } from "./model.js"
import { generatorGlobals } from "./generators.js"
import { promiseGlobal, type PromiseRuntime } from "./promises.js"
import type { Runner } from "./runner.js"
@@ -32,24 +32,27 @@ export type Host<R> = {
// Function.prototype.constructor exists so `fn.constructor === Function` holds; dynamic code is unsupported.
const functionGlobal = <R>(runner: Runner<R>) => {
const reject = () =>
const reject = (_: unknown, __: Array<unknown>, node: AstNode) =>
Effect.sync(() => {
throw typeError("The Function constructor is not supported; write the function inline.")
throw new InterpreterRuntimeError("The Function constructor is not supported; write the function inline.", node)
})
return constructor<R>(runner.prototypes, runner.prototypes.Function, {
name: "Function",
length: 1,
call: reject,
construct: reject,
construct: (args, _, node) => reject(undefined, args, node),
})
}
const symbolGlobal = <R>(runner: Runner<R>) => {
const symbol = native<R>(runner.prototypes, {
name: "Symbol",
call: () =>
call: (_, __, node) =>
Effect.sync(() => {
throw typeError("Symbol is not callable; only Symbol.asyncIterator and Symbol.iterator are available.")
throw new InterpreterRuntimeError(
"Symbol is not callable; only Symbol.asyncIterator and Symbol.iterator are available.",
node,
)
}),
callback: false,
})
+18 -18
View File
@@ -1,7 +1,6 @@
import type { Node } from "acorn"
import type { ErrorType } from "./intrinsics.js"
import type { DiagnosticKind } from "../codemode.js"
import type { ProgramError } from "./objects.js"
/** Any parsed node; the interpreter narrows on `type` and reads `loc` for diagnostics. */
export type AstNode = Node
@@ -34,31 +33,32 @@ export class GeneratorReturn {
export const OptionalShortCircuit: unique symbol = Symbol("codemode.optional-short-circuit")
/**
* A failure raised by the interpreter or a built-in. It travels as a defect and becomes one program Error object
* the first time a handler observes it, so every observer of the same failure sees the same value.
*/
export class PendingThrow {
node?: AstNode
value?: ProgramError
export class InterpreterRuntimeError extends Error {
readonly node?: AstNode
constructor(
/** The JS error class a program sees when it catches this failure. */
readonly type: ErrorType,
readonly message: string,
message: string,
node?: AstNode,
readonly kind: DiagnosticKind = "ExecutionFailure",
readonly suggestions?: ReadonlyArray<string>,
/** The JS error class a program sees when it catches this failure. */
readonly type: ErrorType = "TypeError",
) {
super(message)
this.name = "InterpreterRuntimeError"
if (node) this.node = node
}
}
const failure = (type: ErrorType, kind?: DiagnosticKind) => (message: string, node?: AstNode) =>
new PendingThrow(type, message, node, kind)
/** Attaches a source location to a failure raised where none was known, such as inside a property accessor. */
export const locate = (error: unknown, node: AstNode): unknown =>
error instanceof InterpreterRuntimeError && error.node === undefined
? new InterpreterRuntimeError(error.message, node, error.kind, error.suggestions, error.type)
: error
const failure = (type: ErrorType) => (message: string, node?: AstNode) =>
new InterpreterRuntimeError(message, node, "ExecutionFailure", undefined, type)
export const typeError = failure("TypeError")
export const invalidData = failure("TypeError", "InvalidDataValue")
export const rangeError = failure("RangeError")
export const referenceError = failure("ReferenceError")
export const syntaxError = failure("SyntaxError")
@@ -68,13 +68,13 @@ export const uriError = failure("URIError")
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): PendingThrow =>
new PendingThrow(
"SyntaxError",
export const unsupportedSyntax = (kind: string, node: AstNode): InterpreterRuntimeError =>
new InterpreterRuntimeError(
`Syntax '${kind}' is not supported. ${supportedSyntaxMessage}`,
node,
"UnsupportedSyntax",
[supportedSyntaxMessage],
"SyntaxError",
)
export const isRecord = (value: unknown): value is Record<string, unknown> =>
+13 -10
View File
@@ -1,17 +1,17 @@
import { Effect } from "effect"
import type { Prototypes } from "./intrinsics.js"
import { typeError } from "./model.js"
import { type AstNode, InterpreterRuntimeError } from "./model.js"
import { type Callable, define, frozen, hidden, NativeFunction, type NativeOptions, ProgramObject } from "./objects.js"
import { describeValue } from "./references.js"
/** A native function body: a plain value, a thrown `PendingThrow`, or an Effect. */
export type Impl = (thisValue: unknown, args: Array<unknown>) => unknown
/** A native function body: a plain value, a thrown `InterpreterRuntimeError`, or an Effect. */
export type Impl = (thisValue: unknown, args: Array<unknown>, node: AstNode) => unknown
const lift =
<R>(impl: Impl) =>
(thisValue: unknown, args: Array<unknown>): Effect.Effect<unknown, unknown, R> =>
(thisValue: unknown, args: Array<unknown>, node: AstNode): Effect.Effect<unknown, unknown, R> =>
Effect.suspend(() => {
const result = impl(thisValue, args)
const result = impl(thisValue, args, node)
return Effect.isEffect(result) ? (result as Effect.Effect<unknown, unknown, R>) : Effect.succeed(result)
})
@@ -44,10 +44,12 @@ export const constructor = <R>(
}
/** The `call` of a constructor that JS requires to be invoked with `new`. */
export const requiresNew = (name: string) => (): Effect.Effect<never, unknown, never> =>
Effect.sync(() => {
throw typeError(`Constructor ${name} requires 'new'.`)
})
export const requiresNew =
(name: string) =>
(_: unknown, __: Array<unknown>, node: AstNode): Effect.Effect<never, unknown, never> =>
Effect.sync(() => {
throw new InterpreterRuntimeError(`Constructor ${name} requires 'new'.`, node)
})
/** The instance prototype for `new` via `newTarget.prototype`, falling back to the built-in's own. */
export const prototypeFrom = (newTarget: Callable, fallback: ProgramObject): ProgramObject => {
@@ -60,7 +62,8 @@ export const receiver = <T extends ProgramObject>(
cls: abstract new (...args: never) => T,
thisValue: unknown,
method: string,
node?: AstNode,
): T => {
if (thisValue instanceof cls) return thisValue
throw typeError(`${method} called on incompatible receiver ${describeValue(thisValue)}.`)
throw new InterpreterRuntimeError(`${method} called on incompatible receiver ${describeValue(thisValue)}.`, node)
}
+17 -14
View File
@@ -1,12 +1,6 @@
import type { BlockStatement, Expression, Pattern } from "acorn"
import type { Effect, Fiber } from "effect"
import {
AsyncIteratorSymbol,
type Binding,
type GeneratorRequestKind,
IteratorSymbol,
type PendingThrow,
} from "./model.js"
import { type AstNode, AsyncIteratorSymbol, type Binding, type GeneratorRequestKind, IteratorSymbol } from "./model.js"
/** Property attributes, as in a JS property descriptor. */
export type Attributes = {
@@ -48,10 +42,7 @@ export class ProgramArray extends ProgramObject {
}
/** An object with the [[ErrorData]] slot: what `Error.prototype.toString` and the host boundary recognize as an error. */
export class ProgramError extends ProgramObject {
/** The interpreter failure this error materialized from, so rethrowing it keeps the diagnostic kind and location. */
host?: PendingThrow
}
export class ProgramError extends ProgramObject {}
export abstract class Callable extends ProgramObject {
constructor(proto: ProgramObject, name: string, length: number) {
@@ -76,8 +67,16 @@ export class ProgramFunction extends Callable {
}
}
export type NativeCall<R> = (thisValue: unknown, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
export type NativeConstruct<R> = (args: Array<unknown>, newTarget: Callable) => Effect.Effect<unknown, unknown, R>
export type NativeCall<R> = (
thisValue: unknown,
args: Array<unknown>,
node: AstNode,
) => Effect.Effect<unknown, unknown, R>
export type NativeConstruct<R> = (
args: Array<unknown>,
newTarget: Callable,
node: AstNode,
) => Effect.Effect<unknown, unknown, R>
export type NativeOptions<R> = {
readonly name: string
@@ -115,7 +114,11 @@ export class ProgramGenerator extends ProgramObject {
constructor(
proto: ProgramObject,
readonly asynchronous: boolean,
readonly request: (kind: GeneratorRequestKind, value: unknown) => Effect.Effect<unknown, unknown, unknown>,
readonly request: (
kind: GeneratorRequestKind,
value: unknown,
node: AstNode,
) => Effect.Effect<unknown, unknown, unknown>,
) {
super(proto)
}
+52 -35
View File
@@ -1,6 +1,6 @@
import { Cause, Deferred, Effect, Exit, Fiber, Scope } from "effect"
import type { Diagnostic } from "../codemode.js"
import { ProgramThrow, typeError } from "./model.js"
import { type AstNode, InterpreterRuntimeError, ProgramThrow } from "./model.js"
import {
Callable,
define,
@@ -13,7 +13,7 @@ import {
record,
} from "./objects.js"
import { constructor, fn, methods, native, receiver, requiresNew } from "./native.js"
import { createAggregateErrorValue, materialize, normalizeError } from "./errors.js"
import { caughtErrorValue, createAggregateErrorValue, normalizeError } from "./errors.js"
import { typeofValue } from "./references.js"
import { applyCollectionCallback, isSupportedCallback, type Runner } from "./runner.js"
@@ -105,14 +105,16 @@ export class PromiseRuntime<R> {
}
}
export const selfResolutionError = (node?: AstNode): InterpreterRuntimeError =>
new InterpreterRuntimeError("Chaining cycle detected: a promise cannot resolve with itself.", node)
export const resolvePromiseValue = <R>(
runner: Runner<R>,
value: unknown,
node: AstNode,
own?: { promise?: ProgramPromise },
): Effect.Effect<unknown, unknown, R> => {
if (own?.promise !== undefined && value === own.promise) {
return Effect.die(typeError("Chaining cycle detected: a promise cannot resolve with itself."))
}
if (own?.promise !== undefined && value === own.promise) return Effect.fail(selfResolutionError(node))
if (value instanceof ProgramPromise) return runner.settlePromise(value)
if (!(value instanceof ProgramObject)) return Effect.succeed(value)
const then = get(value, "then")
@@ -126,12 +128,12 @@ export const resolvePromiseValue = <R>(
const reject = capability(runner, "reject", (reason) =>
Deferred.doneUnsafe(deferred, Exit.fail(new ProgramThrow(reason))),
)
const executed = yield* Effect.exit(runner.invokeCallable(then, value, [resolve, reject]))
const executed = yield* Effect.exit(runner.invokeCallable(then, value, [resolve, reject], node))
if (!Exit.isSuccess(executed)) {
if (Cause.hasInterruptsOnly(executed.cause)) return yield* Effect.failCause(executed.cause)
Deferred.doneUnsafe(deferred, Exit.fail(Cause.squash(executed.cause)))
}
return yield* resolvePromiseValue(runner, yield* Deferred.await(deferred), own)
return yield* resolvePromiseValue(runner, yield* Deferred.await(deferred), node, own)
})
}
@@ -139,9 +141,10 @@ export const resolvePromise = <R>(
runner: Runner<R>,
promises: PromiseRuntime<R>,
value: unknown,
node: AstNode,
): Effect.Effect<ProgramPromise, never, R> => {
if (value instanceof ProgramPromise) return Effect.succeed(value)
return promises.createWithSelf((self) => resolvePromiseValue(runner, value, self))
return promises.createWithSelf((self) => resolvePromiseValue(runner, value, node, self))
}
const promiseStatics = ["all", "allSettled", "race", "any", "resolve", "reject"] as const
@@ -151,9 +154,10 @@ const invokePromiseMethod = <R>(
promises: PromiseRuntime<R>,
name: (typeof promiseStatics)[number],
args: Array<unknown>,
node: AstNode,
): Effect.Effect<unknown, unknown, R> => {
if (name === "resolve") {
return resolvePromise(runner, promises, args[0])
return resolvePromise(runner, promises, args[0], node)
}
if (name === "reject") {
return promises.create(Effect.fail(new ProgramThrow(args[0])))
@@ -161,13 +165,15 @@ const invokePromiseMethod = <R>(
return promises.create(
Effect.gen(function* () {
const cursor = yield* runner.syncIterator(args[0])
if (cursor === undefined) throw typeError(`Promise.${name} expects an array or other synchronous iterable.`)
const cursor = yield* runner.syncIterator(args[0], node)
if (cursor === undefined) {
throw new InterpreterRuntimeError(`Promise.${name} expects an array or other synchronous iterable.`, node)
}
const items: Array<ProgramPromise> = []
while (true) {
const step = yield* cursor.next
if (step.done) break
const item = yield* resolvePromise(runner, promises, step.value)
const item = yield* resolvePromise(runner, promises, step.value, node)
promises.markObserved(item)
items.push(item)
}
@@ -195,7 +201,7 @@ const invokePromiseMethod = <R>(
outcomes.push(
record(runner.prototypes.Object, {
status: "rejected",
reason: materialize(runner, Cause.squash(exit.cause)),
reason: caughtErrorValue(runner, Cause.squash(exit.cause)),
}),
)
}
@@ -204,7 +210,10 @@ const invokePromiseMethod = <R>(
}
if (name === "race") {
if (items.length === 0) {
throw typeError("Promise.race([]) would never settle; provide at least one promise or value.")
throw new InterpreterRuntimeError(
"Promise.race([]) would never settle; provide at least one promise or value.",
node,
)
}
return yield* settleAfterTurn(Effect.flatten(Effect.raceAll(items.map((item) => promises.await(item)))))
}
@@ -212,7 +221,7 @@ const invokePromiseMethod = <R>(
Effect.flatMap(promises.await(item), (exit) => {
if (Exit.isSuccess(exit)) return Effect.fail(new PromiseAnyFulfilled(exit.value))
if (Cause.hasInterruptsOnly(exit.cause)) return Effect.failCause(exit.cause)
return Effect.succeed(materialize(runner, Cause.squash(exit.cause)))
return Effect.succeed(caughtErrorValue(runner, Cause.squash(exit.cause)))
}),
)
return yield* settleAfterTurn(
@@ -235,36 +244,41 @@ const instanceMethod = <R>(
name: "then" | "catch" | "finally",
thisValue: unknown,
args: Array<unknown>,
node: AstNode,
): Effect.Effect<ProgramPromise, unknown, R> => {
const method = `Promise.prototype.${name}`
const promise = receiver(ProgramPromise, thisValue, method)
const promise = receiver(ProgramPromise, thisValue, method, node)
promises.markObserved(promise)
if (name === "finally") {
return chainFinally(runner, promises, promise, reactionHandler(args[0], method), method)
return chainFinally(runner, promises, promise, reactionHandler(args[0], method, node), method, node)
}
const onFulfilled = name === "then" ? reactionHandler(args[0], method) : undefined
const onRejected = reactionHandler(name === "then" ? args[1] : args[0], method)
return chainReaction(runner, promises, promise, onFulfilled, onRejected, method)
const onFulfilled = name === "then" ? reactionHandler(args[0], method, node) : undefined
const onRejected = reactionHandler(name === "then" ? args[1] : args[0], method, node)
return chainReaction(runner, promises, promise, onFulfilled, onRejected, method, node)
}
const constructPromise = <R>(
runner: Runner<R>,
promises: PromiseRuntime<R>,
executor: unknown,
node: AstNode,
): Effect.Effect<ProgramPromise, unknown, R> => {
if (!(executor instanceof ProgramFunction)) {
throw typeError("new Promise(...) expects an executor function (e.g. new Promise((resolve, reject) => { ... })).")
throw new InterpreterRuntimeError(
"new Promise(...) expects an executor function (e.g. new Promise((resolve, reject) => { ... })).",
node,
)
}
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, self)),
Effect.flatMap(Deferred.await(deferred), (value) => resolvePromiseValue(runner, value, node, self)),
)
const resolve = capability(runner, "resolve", (value) => Deferred.doneUnsafe(deferred, Exit.succeed(value)))
const reject = capability(runner, "reject", (value) =>
Deferred.doneUnsafe(deferred, Exit.fail(new ProgramThrow(value))),
)
const executed = yield* Effect.exit(runner.invokeCallable(executor, undefined, [resolve, reject]))
const executed = yield* Effect.exit(runner.invokeCallable(executor, undefined, [resolve, reject], node))
if (!Exit.isSuccess(executed)) {
if (Cause.hasInterruptsOnly(executed.cause)) return yield* Effect.failCause(executed.cause)
Deferred.doneUnsafe(deferred, Exit.fail(Cause.squash(executed.cause)))
@@ -281,11 +295,12 @@ class PromiseAnyFulfilled {
constructor(readonly value: unknown) {}
}
const reactionHandler = (value: unknown, method: string): Callable | undefined => {
const reactionHandler = (value: unknown, method: string, node: AstNode): Callable | undefined => {
if (isSupportedCallback(value)) return value
if (typeofValue(value) === "function") {
throw typeError(
throw new InterpreterRuntimeError(
`${method} cannot use this callable as a handler; wrap it in an arrow function, e.g. (value) => tools.ns.tool(value).`,
node,
)
}
return undefined
@@ -310,15 +325,16 @@ const chainReaction = <R>(
onFulfilled: Callable | undefined,
onRejected: Callable | undefined,
method: string,
node: AstNode,
): Effect.Effect<ProgramPromise, 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 : materialize(runner, Cause.squash(exit.cause))
const result = yield* applyCollectionCallback(runner, handler, method)([input])
return yield* resolvePromiseValue(runner, result, self)
const input = Exit.isSuccess(exit) ? exit.value : caughtErrorValue(runner, Cause.squash(exit.cause))
const result = yield* applyCollectionCallback(runner, handler, method, node)([input])
return yield* resolvePromiseValue(runner, result, node, self)
}),
)
}
@@ -329,15 +345,16 @@ const chainFinally = <R>(
source: ProgramPromise,
cleanup: Callable | undefined,
method: string,
node: AstNode,
): Effect.Effect<ProgramPromise, never, R> =>
promises.create(
Effect.gen(function* () {
const exit = yield* reactionExit(promises, source)
if (cleanup !== undefined) {
const result = yield* applyCollectionCallback(runner, cleanup, method)([])
const result = yield* applyCollectionCallback(runner, cleanup, method, node)([])
const intermediate = yield* promises.create(
Effect.gen(function* () {
yield* runner.settlePromise(yield* resolvePromise(runner, promises, result))
yield* runner.settlePromise(yield* resolvePromise(runner, promises, result, node))
return yield* exit
}),
)
@@ -354,7 +371,7 @@ export const promiseGlobal = <R>(runner: Runner<R>, promises: PromiseRuntime<R>)
name: "Promise",
length: 1,
call: requiresNew("Promise"),
construct: (args) => constructPromise(runner, promises, args[0]),
construct: (args, _, node) => constructPromise(runner, promises, args[0], node),
})
// Combinators are not callbacks: `[p].map(Promise.resolve)` must ask for an arrow function.
for (const name of promiseStatics) {
@@ -364,16 +381,16 @@ export const promiseGlobal = <R>(runner: Runner<R>, promises: PromiseRuntime<R>)
native<R>(protos, {
name,
length: 1,
call: (_, args) => invokePromiseMethod(runner, promises, name, args),
call: (_, args, node) => invokePromiseMethod(runner, promises, name, args, node),
callback: false,
}),
hidden,
)
}
methods(protos, proto, [
["then", 2, (thisValue, args) => instanceMethod(runner, promises, "then", thisValue, args)],
["catch", 1, (thisValue, args) => instanceMethod(runner, promises, "catch", thisValue, args)],
["finally", 1, (thisValue, args) => instanceMethod(runner, promises, "finally", thisValue, args)],
["then", 2, (thisValue, args, node) => instanceMethod(runner, promises, "then", thisValue, args, node)],
["catch", 1, (thisValue, args, node) => instanceMethod(runner, promises, "catch", thisValue, args, node)],
["finally", 1, (thisValue, args, node) => instanceMethod(runner, promises, "finally", thisValue, args, node)],
])
return promise
}
@@ -1,5 +1,5 @@
import { ToolReference } from "../tool-runtime.js"
import { invalidData } from "./model.js"
import { type AstNode, InterpreterRuntimeError } from "./model.js"
import {
Callable,
getOwn,
@@ -66,10 +66,11 @@ export const rejectCircularInsertion = (
container: object,
value: unknown,
label: string,
node: AstNode,
seen = new Set<object>(),
): void => {
if (find(value, (current) => current === container, isRuntimeReference, seen)) {
throw invalidData(`${label} contains a circular value.`)
throw new InterpreterRuntimeError(`${label} contains a circular value.`, node, "InvalidDataValue")
}
}
+15 -11
View File
@@ -1,7 +1,7 @@
import { Effect, Exit } from "effect"
import { coerceToNumber, coerceToString } from "../stdlib/value.js"
import type { Prototypes } from "./intrinsics.js"
import { typeError } from "./model.js"
import { type AstNode, InterpreterRuntimeError } from "./model.js"
import { Callable, get, NativeFunction, ProgramDate, ProgramObject, ProgramPromise } from "./objects.js"
import { typeofValue } from "./references.js"
@@ -16,9 +16,10 @@ export type Runner<R> = {
callable: unknown,
thisValue: unknown,
args: Array<unknown>,
node: AstNode,
) => Effect.Effect<unknown, unknown, R>
readonly settlePromise: (promise: ProgramPromise) => Effect.Effect<unknown, unknown, never>
readonly syncIterator: (value: unknown) => Effect.Effect<IteratorCursor<R> | undefined, unknown, R>
readonly syncIterator: (value: unknown, node: AstNode) => Effect.Effect<IteratorCursor<R> | undefined, unknown, R>
readonly prototypes: Prototypes
}
@@ -40,6 +41,7 @@ export const toPrimitive = <R>(
runner: Runner<R>,
value: unknown,
hint: "number" | "string" | "default",
node: AstNode,
): Effect.Effect<unknown, unknown, R> => {
if (!(value instanceof ProgramObject)) return Effect.succeed(value)
const asString = hint === "string" || (hint === "default" && value instanceof ProgramDate)
@@ -48,18 +50,18 @@ export const toPrimitive = <R>(
for (const method of order) {
const callable = get(value, method)
if (!(callable instanceof Callable)) continue
const result = yield* runner.invokeCallable(callable, value, [])
const result = yield* runner.invokeCallable(callable, value, [], node)
if (result === null || (typeof result !== "object" && typeof result !== "function")) return result
}
throw typeError("Cannot convert object to primitive value.")
throw new InterpreterRuntimeError("Cannot convert object to primitive value.", node)
})
}
export const toPrimitiveString = <R>(runner: Runner<R>, value: unknown) =>
Effect.map(toPrimitive(runner, value, "string"), coerceToString)
export const toPrimitiveString = <R>(runner: Runner<R>, value: unknown, node: AstNode) =>
Effect.map(toPrimitive(runner, value, "string", node), coerceToString)
export const toPrimitiveNumber = <R>(runner: Runner<R>, value: unknown) =>
Effect.map(toPrimitive(runner, value, "number"), coerceToNumber)
export const toPrimitiveNumber = <R>(runner: Runner<R>, value: unknown, node: AstNode) =>
Effect.map(toPrimitive(runner, value, "number", node), coerceToNumber)
// The single acceptance list for callbacks: collections, sort, string replacers,
// Array.from mappers, and promise reactions all admit exactly these callables.
@@ -72,14 +74,16 @@ export const applyCollectionCallback = <R>(
runner: Runner<R>,
callback: unknown,
name: string,
node: AstNode,
): ((args: Array<unknown>) => Effect.Effect<unknown, unknown, R>) => {
if (!isSupportedCallback(callback)) {
if (typeofValue(callback) === "function") {
throw typeError(
throw new InterpreterRuntimeError(
`${name} cannot use this callable as a callback; wrap it in an arrow function, e.g. (value) => tools.ns.tool(value).`,
node,
)
}
throw typeError(`${name} expects a function callback.`)
throw new InterpreterRuntimeError(`${name} expects a function callback.`, node)
}
return (callbackArgs) => runner.invokeCallable(callback, undefined, callbackArgs)
return (callbackArgs) => runner.invokeCallable(callback, undefined, callbackArgs, node)
}
+105 -109
View File
@@ -50,16 +50,16 @@ import {
type Binding,
type GeneratorRequestKind,
GeneratorReturn,
InterpreterRuntimeError,
IteratorSymbol,
locate,
OptionalShortCircuit,
invalidData,
ProgramThrow,
rangeError,
type StatementResult,
typeError,
unsupportedSyntax,
} from "./model.js"
import { locate, materialize } from "./errors.js"
import { caughtErrorValue } from "./errors.js"
import type { Prototypes } from "./intrinsics.js"
import { globals, type Host } from "./globals.js"
import {
@@ -124,11 +124,11 @@ const calleeDescription = (callee: Expression | Super | undefined): string => {
// OrdinaryHasInstance: walk the left operand's chain looking for the constructor's `prototype`.
const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean => {
if (!(rhs instanceof Callable)) {
throw typeError("The right-hand side of 'instanceof' is not callable.", node)
throw new InterpreterRuntimeError("The right-hand side of 'instanceof' is not callable.", node)
}
const prototype = get(rhs, "prototype")
if (!(prototype instanceof ProgramObject)) {
throw typeError("The right-hand side of 'instanceof' has no 'prototype' object.", node)
throw new InterpreterRuntimeError("The right-hand side of 'instanceof' has no 'prototype' object.", node)
}
return hasPrototype(lhs, prototype)
}
@@ -207,7 +207,7 @@ const loopDeclaration = (left: VariableDeclaration | Pattern, statement: "for...
if (left.type !== "VariableDeclaration") return undefined
const declaration = left.declarations.length === 1 ? left.declarations[0] : undefined
if (declaration === undefined) {
throw typeError(`${statement} supports one declared binding.`, left)
throw new InterpreterRuntimeError(`${statement} supports one declared binding.`, left)
}
const kind = left.kind
return {
@@ -246,6 +246,8 @@ type GeneratorState = {
available?: Deferred.Deferred<void>
}
const promiseResolutionNode: AstNode = { type: "PromiseResolution", start: 0, end: 0 }
/** One program execution: the tool bridge, promise scheduler, captured logs, and the global scope built once. */
export class Runtime<R> {
readonly runner: Runner<R>
@@ -264,9 +266,9 @@ export class Runtime<R> {
// Calling back into the program never reads frame state, so any frame serves; the root is always alive.
this.root = new Frame(this, new ScopeStack([globalScope]))
this.runner = {
invokeCallable: (callable, thisValue, args) => this.root.invokeCallable(callable, thisValue, args),
invokeCallable: (callable, thisValue, args, node) => this.root.invokeCallable(callable, thisValue, args, node),
settlePromise: (promise) => this.root.settlePromise(promise),
syncIterator: (value) => this.root.syncIterator(value),
syncIterator: (value, node) => this.root.syncIterator(value, node),
prototypes,
}
for (const [name, value] of [...globals(this), ...extraGlobals(this)]) {
@@ -311,12 +313,12 @@ class Frame<R> {
}
if (result.kind === "break" || result.kind === "continue") {
throw typeError(`Unexpected '${result.kind}' outside of a loop.`, statement)
throw new InterpreterRuntimeError(`Unexpected '${result.kind}' outside of a loop.`, statement)
}
}
// The implicit async body adopts returned promises before copy-out.
value = yield* resolvePromiseValue(self.runtime.runner, value)
value = yield* resolvePromiseValue(self.runtime.runner, value, program)
return value
}).pipe(Effect.ensuring(Effect.sync(() => self.scopes.pop())))
}
@@ -481,7 +483,7 @@ class Frame<R> {
return Effect.gen(function* () {
const discriminant = yield* self.evaluateExpression(node.discriminant)
if (containsOpaqueReference(discriminant)) {
throw invalidData("Switch discriminants must be data values.", node)
throw new InterpreterRuntimeError("Switch discriminants must be data values.", node, "InvalidDataValue")
}
self.scopes.push()
return yield* Effect.gen(function* () {
@@ -499,7 +501,7 @@ class Frame<R> {
}
const candidate = yield* self.evaluateExpression(test)
if (containsOpaqueReference(candidate)) {
throw invalidData("Switch case values must be data values.", test)
throw new InterpreterRuntimeError("Switch case values must be data values.", test, "InvalidDataValue")
}
if (candidate === discriminant) {
selected = index
@@ -622,7 +624,7 @@ class Frame<R> {
const iterator = yield* self.customIterator(right, node, awaiting)
const cursor = iterator === undefined ? yield* self.syncIterator(right, node) : undefined
if (iterator === undefined && cursor === undefined) {
throw invalidData(
throw new InterpreterRuntimeError(
`${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, or URLSearchParams, or custom iterator value.`,
node,
)
@@ -635,7 +637,7 @@ class Frame<R> {
: (cursor?.close ?? Effect.void)
if (left.type === "RestElement" || left.type === "AssignmentPattern") {
throw typeError("Unsupported for...of binding.", left)
throw new InterpreterRuntimeError("Unsupported for...of binding.", left)
}
const assignment = left.type === "VariableDeclaration" ? undefined : left
@@ -662,7 +664,7 @@ class Frame<R> {
while (true) {
const current = iterator
? yield* self.nextIteratorResult(iterator, node, awaiting)
: yield* cursor?.next ?? Effect.die(typeError("Iterator is unavailable.", node))
: yield* cursor?.next ?? Effect.fail(new InterpreterRuntimeError("Iterator is unavailable.", node))
const step = cursor && awaiting ? { done: current.done, value: yield* self.awaitValue(current.value) } : current
if (step.done) return { kind: "none" } satisfies StatementResult
const bodyExit = yield* Effect.exit(evaluateBody(step.value))
@@ -688,8 +690,8 @@ class Frame<R> {
)
}
private awaitValue(value: unknown): Effect.Effect<unknown, unknown, R> {
return Effect.flatMap(resolvePromise(this.runtime.runner, this.runtime.promises, value), (promise) =>
private awaitValue(value: unknown, node: AstNode = promiseResolutionNode): Effect.Effect<unknown, unknown, R> {
return Effect.flatMap(resolvePromise(this.runtime.runner, this.runtime.promises, value, node), (promise) =>
this.settlePromise(promise),
)
}
@@ -697,7 +699,7 @@ class Frame<R> {
private awaitAsyncFromSyncValue(
iterator: CustomIterator,
value: unknown,
node: AstNode | undefined,
node: AstNode,
closeOnRejection: boolean,
): Effect.Effect<unknown, unknown, R> {
const self = this
@@ -711,7 +713,7 @@ class Frame<R> {
})
}
syncIterator(value: unknown, node?: AstNode) {
syncIterator(value: unknown, node: AstNode) {
const iterator =
value instanceof ProgramArray
? value.items[Symbol.iterator]()
@@ -748,7 +750,7 @@ class Frame<R> {
)
}
private customIterator(value: unknown, node: AstNode | undefined, allowAsync = true) {
private customIterator(value: unknown, node: AstNode, allowAsync = true) {
if (!(value instanceof ProgramObject)) return Effect.undefined
const asyncMethod = allowAsync ? get(value, AsyncIteratorSymbol) : undefined
const method = asyncMethod ?? get(value, IteratorSymbol)
@@ -767,7 +769,7 @@ class Frame<R> {
)
}
private nextIteratorResult(iterator: CustomIterator, node: AstNode | undefined, awaiting: boolean) {
private nextIteratorResult(iterator: CustomIterator, node: AstNode, awaiting: boolean) {
const self = this
return Effect.gen(function* () {
if (iterator.asynchronous) {
@@ -803,11 +805,7 @@ class Frame<R> {
})
}
private closeIterator(
iterator: CustomIterator,
node: AstNode | undefined,
awaiting = true,
): Effect.Effect<void, unknown, R> {
private closeIterator(iterator: CustomIterator, node: AstNode, awaiting = true): Effect.Effect<void, unknown, R> {
const close = get(iterator.iterator, "return")
if (close === undefined || close === null) return iterator.asynchronous || !awaiting ? Effect.void : Effect.yieldNow
const self = this
@@ -838,14 +836,14 @@ class Frame<R> {
})
}
private requireIteratorObject(value: unknown, context: string, node?: AstNode): ProgramObject {
private requireIteratorObject(value: unknown, context: string, node: AstNode): ProgramObject {
if (value instanceof ProgramObject) return value
throw typeError(`${context} must be an object.`, node)
throw new InterpreterRuntimeError(`${context} must be an object.`, node)
}
private requireIteratorMethod(value: unknown, context: string, node?: AstNode): unknown {
private requireIteratorMethod(value: unknown, context: string, node: AstNode): unknown {
if (typeofValue(value) === "function") return value
throw typeError(`${context} must be a function.`, node)
throw new InterpreterRuntimeError(`${context} must be a function.`, node)
}
// for...in over null/undefined iterates nothing, like JS.
@@ -871,7 +869,7 @@ class Frame<R> {
const keys = self.enumerableKeys(right, node.right)
if (left.type !== "Identifier" && left.type !== "VariableDeclaration") {
throw typeError("Unsupported for...in binding.", left)
throw new InterpreterRuntimeError("Unsupported for...in binding.", left)
}
const assignmentName = left.type === "Identifier" ? left.name : undefined
@@ -957,7 +955,7 @@ class Frame<R> {
return Effect.failCause(cause)
}
const caught = materialize(self.runtime.runner, Cause.squash(cause))
const caught = caughtErrorValue(self.runtime.runner, Cause.squash(cause))
const parameter = handler.param
self.scopes.push()
return Effect.gen(function* () {
@@ -993,7 +991,7 @@ class Frame<R> {
return Effect.gen(function* () {
for (const declaration of node.declarations) {
if (declaration.type !== "VariableDeclarator") {
throw typeError("Unsupported variable declaration shape.", declaration)
throw new InterpreterRuntimeError("Unsupported variable declaration shape.", declaration)
}
const init = declaration.init
@@ -1035,9 +1033,10 @@ class Frame<R> {
if (pattern.type === "ObjectPattern") {
if (!(value instanceof ProgramObject)) {
throw typeError(
throw new InterpreterRuntimeError(
`Object destructuring requires a data object or array value, received ${describeValue(value)}.`,
pattern,
"InvalidDataValue",
)
}
@@ -1052,13 +1051,7 @@ class Frame<R> {
const key = yield* self.destructuringPropertyKey(property)
consumed.add(typeof key === "symbol" ? key : String(key))
yield* self.declarePattern(
property.value,
self.readProperty(value, key, property),
mutable,
property,
initialize,
)
yield* self.declarePattern(property.value, get(value, key), mutable, property, initialize)
}
return
}
@@ -1069,7 +1062,7 @@ class Frame<R> {
)
}
throw typeError(`Unsupported binding pattern '${pattern.type}'.`, pattern)
throw new InterpreterRuntimeError(`Unsupported binding pattern '${pattern.type}'.`, pattern)
})
}
@@ -1094,9 +1087,10 @@ class Frame<R> {
if (pattern.type === "ObjectPattern") {
if (!(value instanceof ProgramObject)) {
throw invalidData(
throw new InterpreterRuntimeError(
`Object destructuring requires a data object or array value, received ${describeValue(value)}.`,
pattern,
"InvalidDataValue",
)
}
@@ -1110,7 +1104,7 @@ class Frame<R> {
}
const key = yield* self.destructuringPropertyKey(property)
consumed.add(typeof key === "symbol" ? key : String(key))
yield* self.assignPattern(property.value, self.readProperty(value, key, property), property)
yield* self.assignPattern(property.value, get(value, key), property)
}
return
}
@@ -1121,7 +1115,7 @@ class Frame<R> {
)
}
throw typeError(`Unsupported assignment pattern '${pattern.type}'.`, node)
throw new InterpreterRuntimeError(`Unsupported assignment pattern '${pattern.type}'.`, node)
})
}
@@ -1140,7 +1134,7 @@ class Frame<R> {
return Effect.gen(function* () {
const cursor = yield* self.syncIterator(value, pattern)
if (cursor === undefined) {
throw typeError("Array destructuring requires a supported iterable value.", pattern)
throw new InterpreterRuntimeError("Array destructuring requires a supported iterable value.", pattern)
}
let done = false
for (const element of pattern.elements) {
@@ -1177,7 +1171,7 @@ class Frame<R> {
private destructuringPropertyKey(property: Property | AssignmentProperty): Effect.Effect<PropertyKey, unknown, R> {
if (property.type !== "Property" || property.kind !== "init") {
throw typeError("Unsupported object destructuring property.", property)
throw new InterpreterRuntimeError("Unsupported object destructuring property.", property)
}
const keyNode = property.key
if (property.computed) {
@@ -1192,7 +1186,8 @@ class Frame<R> {
switch (node.type) {
case "Literal": {
const regex = node.regex
if (regex) return Effect.sync(() => constructRegExp(this.runtime.prototypes, [regex.pattern, regex.flags]))
if (regex)
return Effect.sync(() => constructRegExp(this.runtime.prototypes, [regex.pattern, regex.flags], node))
return Effect.sync(() => toProgram(this.runtime.prototypes, node.value, "Literal"))
}
case "Identifier":
@@ -1238,7 +1233,7 @@ class Frame<R> {
return this.evaluateUpdateExpression(node)
case "AwaitExpression": {
// Await always suspends, including for plain values.
return Effect.flatMap(this.evaluateExpression(node.argument), (value) => this.awaitValue(value))
return Effect.flatMap(this.evaluateExpression(node.argument), (value) => this.awaitValue(value, node))
}
case "YieldExpression":
return this.evaluateYieldExpression(node)
@@ -1266,12 +1261,10 @@ class Frame<R> {
: callee instanceof NativeFunction
? `new ${name}(...) is not supported; call ${name}(...) without new instead.`
: `${name} is not a constructor.`
throw typeError(message, node)
throw new InterpreterRuntimeError(message, node)
}
const args = yield* self.evaluateCallArguments(node.arguments)
return yield* Effect.catchDefect(construct(args, callee as NativeFunction<R>), (defect) =>
Effect.die(locate(defect, node)),
)
return yield* construct(args, callee as NativeFunction<R>, node)
})
}
@@ -1299,7 +1292,7 @@ class Frame<R> {
return has(rhs, lhs !== null && typeof lhs === "object" ? coerceToString(lhs) : (lhs as PropertyKey))
}
if (containsOpaqueReference(lhs) || containsOpaqueReference(rhs)) {
throw invalidData("Binary operators require data values.", node)
throw new InterpreterRuntimeError("Binary operators require data values.", node, "InvalidDataValue")
}
// Null-prototype data needs explicit primitive coercion; identity and `in` retain raw objects.
// Dates use their default string hint for addition and loose equality, and epoch time elsewhere.
@@ -1351,11 +1344,11 @@ class Frame<R> {
return (l as number) >>> (r as number)
case "in":
if (!(rhs instanceof ProgramObject)) {
throw typeError("The 'in' operator requires a data object on the right-hand side.", node)
throw new InterpreterRuntimeError("The 'in' operator requires a data object on the right-hand side.", node)
}
return has(rhs, coerceOperand(lhs) as PropertyKey)
default:
throw typeError(`Unsupported binary operator '${operator}'.`, node)
throw new InterpreterRuntimeError(`Unsupported binary operator '${operator}'.`, node)
}
}
@@ -1366,7 +1359,7 @@ class Frame<R> {
if (operator === "||") return left ? Effect.succeed(left) : this.evaluateExpression(node.right)
if (operator === "??")
return left !== null && left !== undefined ? Effect.succeed(left) : this.evaluateExpression(node.right)
throw typeError(`Unsupported logical operator '${operator}'.`, node)
throw new InterpreterRuntimeError(`Unsupported logical operator '${operator}'.`, node)
})
}
@@ -1383,7 +1376,7 @@ class Frame<R> {
if (operator === "!") return !value
if (operator === "void") return undefined
if (containsOpaqueReference(value)) {
throw invalidData("Unary operators require data values.", node)
throw new InterpreterRuntimeError("Unary operators require data values.", node, "InvalidDataValue")
}
const operand =
value instanceof ProgramDate
@@ -1403,7 +1396,7 @@ class Frame<R> {
result = ~(operand as number)
break
default:
throw typeError(`Unsupported unary operator '${operator}'.`, node)
throw new InterpreterRuntimeError(`Unsupported unary operator '${operator}'.`, node)
}
return toProgram(this.runtime.prototypes, result, "Unary expression result")
})
@@ -1450,7 +1443,7 @@ class Frame<R> {
}),
)
}
throw typeError("Assignment target must be an Identifier or MemberExpression.", left)
throw new InterpreterRuntimeError("Assignment target must be an Identifier or MemberExpression.", left)
})
}
@@ -1482,7 +1475,7 @@ class Frame<R> {
: Effect.succeed({ write: false, next: current, result: current }),
)
}
throw typeError("Assignment target must be an Identifier or MemberExpression.", left)
throw new InterpreterRuntimeError("Assignment target must be an Identifier or MemberExpression.", left)
}
private evaluateUpdateExpression(node: UpdateExpression): Effect.Effect<unknown, unknown, R> {
@@ -1493,14 +1486,14 @@ class Frame<R> {
const increment = operator === "++" ? 1 : operator === "--" ? -1 : undefined
if (increment === undefined) {
throw typeError(`Unsupported update operator '${operator}'.`, node)
throw new InterpreterRuntimeError(`Unsupported update operator '${operator}'.`, node)
}
// CodeMode numeric coercion, not host Number(): null-prototype data objects would make
// the host throw during ToPrimitive, and opaque runtime references must reject clearly.
const operand = (current: unknown): number => {
if (containsOpaqueReference(current)) {
throw invalidData(`'${operator}' requires a data value.`, argument)
throw new InterpreterRuntimeError(`'${operator}' requires a data value.`, argument, "InvalidDataValue")
}
return coerceToNumber(current)
}
@@ -1523,7 +1516,7 @@ class Frame<R> {
})
}
throw typeError("Update target must be an Identifier or MemberExpression.", argument)
throw new InterpreterRuntimeError("Update target must be an Identifier or MemberExpression.", argument)
}
// EvaluateCall: a member callee supplies its base object as `this`; anything else calls with undefined.
@@ -1559,24 +1552,20 @@ class Frame<R> {
callable: unknown,
thisValue: unknown,
args: Array<unknown>,
node?: AstNode,
node: AstNode,
callee?: Expression,
): Effect.Effect<unknown, unknown, R> {
const self = this
return Effect.gen(function* () {
if (callable instanceof ToolReference) {
if (callable.path.length === 0) {
throw typeError("The tools root is not callable.", callee ?? node)
throw new InterpreterRuntimeError("The tools root is not callable.", callee ?? node)
}
return yield* self.createToolCallPromise(callable.path, args)
}
if (callable instanceof ProgramFunction) return yield* self.invokeFunction(callable, args)
if (callable instanceof NativeFunction) {
return yield* Effect.catchDefect((callable as NativeFunction<R>).call(thisValue, args), (defect) =>
Effect.die(locate(defect, node)),
)
}
throw typeError(`${calleeDescription(callee)} is not a function.`, callee ?? node)
if (callable instanceof NativeFunction) return yield* (callable as NativeFunction<R>).call(thisValue, args, node)
throw new InterpreterRuntimeError(`${calleeDescription(callee)} is not a function.`, callee ?? node)
})
}
@@ -1590,7 +1579,8 @@ class Frame<R> {
if (argNode.type === "SpreadElement") {
const spread = yield* self.evaluateExpression(argNode.argument)
const cursor = yield* self.syncIterator(spread, argNode)
if (cursor === undefined) throw typeError("Spread arguments require a synchronous iterable.", argNode)
if (cursor === undefined)
throw new InterpreterRuntimeError("Spread arguments require a synchronous iterable.", argNode)
while (true) {
const step = yield* cursor.next
if (step.done) break
@@ -1641,7 +1631,7 @@ 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, self)),
Effect.flatMap(run, (value) => resolvePromiseValue(invocation.runtime.runner, value, fn.body, self)),
)
}
@@ -1655,9 +1645,11 @@ class Frame<R> {
invocation.generatorAsync = asynchronous
const protos = this.runtime.prototypes
const result = (value: unknown, done: boolean) => record(protos.Object, { value, done })
const request = (kind: GeneratorRequestKind, value: unknown) => {
const request = (kind: GeneratorRequestKind, value: unknown, node: AstNode) => {
const request = { kind, value, response: Deferred.makeUnsafe<unknown, unknown>() }
if (!asynchronous && state.active) return Effect.die(typeError("Generator is already running."))
if (!asynchronous && state.active) {
return Effect.fail(new InterpreterRuntimeError("Generator is already running.", node))
}
if (asynchronous && (state.completed || (!state.started && kind !== "next"))) {
state.started = true
state.completed = true
@@ -1777,7 +1769,7 @@ class Frame<R> {
const argument = node.argument
const self = this
return Effect.gen(function* () {
if (!self.generatorState) throw typeError("yield is only valid inside a generator.", node)
if (!self.generatorState) throw new InterpreterRuntimeError("yield is only valid inside a generator.", node)
if (node.delegate) {
const value = argument ? yield* self.evaluateExpression(argument) : undefined
return yield* self.delegateYield(value, node)
@@ -1790,7 +1782,7 @@ class Frame<R> {
private suspendGenerator(value: unknown, node: AstNode): Effect.Effect<unknown, unknown, R> {
const state = this.generatorState
if (!state?.active) throw typeError("Generator has no active request.", node)
if (!state?.active) throw new InterpreterRuntimeError("Generator has no active request.", node)
Deferred.doneUnsafe(
state.active.response,
Exit.succeed(record(this.runtime.prototypes.Object, { value, done: false })),
@@ -1817,7 +1809,7 @@ class Frame<R> {
value instanceof ProgramURLSearchParams
) {
const cursor = yield* self.syncIterator(value, node)
if (!cursor) throw typeError("Built-in iterator is unavailable.", node)
if (!cursor) throw new InterpreterRuntimeError("Built-in iterator is unavailable.", node)
while (true) {
const step = yield* cursor.next
if (step.done) return undefined
@@ -1832,14 +1824,14 @@ class Frame<R> {
}
if (error instanceof ProgramThrow) {
yield* cursor.close
throw typeError("The delegated iterator does not provide a throw() method.", node)
throw new InterpreterRuntimeError("The delegated iterator does not provide a throw() method.", node)
}
return yield* Effect.failCause(resumed.cause)
}
}
const iterator = yield* self.customIterator(value, node, self.generatorAsync)
if (!iterator) throw typeError("yield* requires a compatible iterable value.", node)
if (!iterator) throw new InterpreterRuntimeError("yield* requires a compatible iterable value.", node)
let kind: GeneratorRequestKind = "next"
let input: unknown = undefined
while (true) {
@@ -1847,7 +1839,7 @@ class Frame<R> {
if (method === undefined || method === null) {
if (kind === "return") return yield* Effect.fail(new GeneratorReturn(input))
yield* self.closeIterator(iterator, node, self.generatorAsync)
throw typeError("The delegated iterator does not provide a throw() method.", node)
throw new InterpreterRuntimeError("The delegated iterator does not provide a throw() method.", node)
}
const called = yield* self.invokeCallable(
self.requireIteratorMethod(method, `Iterator ${kind}`, node),
@@ -1899,7 +1891,7 @@ class Frame<R> {
}
if (property.kind !== "init") {
throw typeError("Only init object properties are supported.", property)
throw new InterpreterRuntimeError("Only init object properties are supported.", property)
}
const keyNode = property.key
@@ -1913,7 +1905,7 @@ class Frame<R> {
} else if (keyNode.type === "Literal") {
key = self.toPropertyKey(keyNode.value, keyNode)
} else {
throw typeError("Unsupported object property key shape.", keyNode)
throw new InterpreterRuntimeError("Unsupported object property key shape.", keyNode)
}
const name =
@@ -1943,7 +1935,8 @@ class Frame<R> {
if (element.type === "SpreadElement") {
const spread = yield* self.evaluateExpression(element.argument)
const cursor = yield* self.syncIterator(spread, element)
if (cursor === undefined) throw typeError("Array spread requires a synchronous iterable.", element)
if (cursor === undefined)
throw new InterpreterRuntimeError("Array spread requires a synchronous iterable.", element)
while (true) {
const step = yield* cursor.next
if (step.done) break
@@ -1969,7 +1962,7 @@ class Frame<R> {
const quasi = quasis[index]!
// acorn only omits `cooked` for invalid escapes in tagged templates, which are unsupported.
if (typeof quasi.value.cooked !== "string") {
throw typeError("Invalid template literal quasi.", quasi)
throw new InterpreterRuntimeError("Invalid template literal quasi.", quasi)
}
output += quasi.value.cooked
@@ -1991,7 +1984,7 @@ class Frame<R> {
private applyCompoundAssignment(operator: string, current: unknown, incoming: unknown, node: AstNode): unknown {
if (!compoundOperators.has(operator)) {
throw typeError(`Unsupported assignment operator '${operator}'.`, node)
throw new InterpreterRuntimeError(`Unsupported assignment operator '${operator}'.`, node)
}
return this.applyBinaryOperator(operator.slice(0, -1), current, incoming, node)
}
@@ -2017,7 +2010,7 @@ class Frame<R> {
if (objectValue instanceof ToolReference) {
if (typeof key !== "string") {
throw typeError("Tool paths must use string property names.", propertyNode)
throw new InterpreterRuntimeError("Tool paths must use string property names.", propertyNode)
}
return new ToolReference([...objectValue.path, key])
}
@@ -2036,27 +2029,26 @@ class Frame<R> {
if (typeof objectValue === "boolean") return { target: protos.Boolean, key, receiver: objectValue }
if (objectValue === null || objectValue === undefined) {
throw typeError(`Cannot read properties of ${objectValue} (reading '${String(key)}').`, objectNode)
throw new InterpreterRuntimeError(
`Cannot read properties of ${objectValue} (reading '${String(key)}').`,
objectNode,
)
}
throw typeError("Cannot access a property on a non-object value.", objectNode)
throw new InterpreterRuntimeError("Cannot access a property on a non-object value.", objectNode)
})
}
private readReference(reference: MemberReference, node: MemberExpression): unknown {
// Reject unknown promise properties so a missing await cannot hide.
if (reference.target instanceof ProgramPromise && !has(reference.target, reference.key)) {
throw invalidData(
throw new InterpreterRuntimeError(
"This value is an un-awaited Promise; await it first - e.g. `const result = await tools.ns.tool(...)`.",
node.object,
"InvalidDataValue",
)
}
return this.readProperty(reference.target, reference.key, node, reference.receiver)
}
// Accessors throw without a location; the member or pattern that read them supplies it.
private readProperty(target: ProgramObject, key: PropertyKey, node: AstNode, receiver: unknown = target): unknown {
try {
return get(target, key, receiver)
return get(reference.target, reference.key, reference.receiver)
} catch (error) {
throw locate(error, node)
}
@@ -2078,15 +2070,15 @@ class Frame<R> {
private evaluateDeleteExpression(argument: Expression): Effect.Effect<boolean, unknown, R> {
const target = argument.type === "ChainExpression" ? argument.expression : argument
if (target.type !== "MemberExpression") {
throw typeError("Only data fields may be deleted.", argument)
throw new InterpreterRuntimeError("Only data fields may be deleted.", argument)
}
return Effect.map(this.getMemberReference(target), (reference) => {
if (reference === OptionalShortCircuit) return true
if (reference instanceof ToolReference || "value" in reference || reference.receiver !== reference.target) {
throw invalidData("Only data fields may be deleted.", target)
throw new InterpreterRuntimeError("Only data fields may be deleted.", target, "InvalidDataValue")
}
if (remove(reference.target, reference.key)) return true
throw typeError(`Cannot delete property '${String(reference.key)}'.`, target)
throw new InterpreterRuntimeError(`Cannot delete property '${String(reference.key)}'.`, target)
})
}
@@ -2099,10 +2091,10 @@ class Frame<R> {
return Effect.gen(function* () {
const reference = yield* self.getMemberReference(node)
if (reference === OptionalShortCircuit || reference instanceof ToolReference || "value" in reference) {
throw typeError("Only data fields may be assigned.", node)
throw new InterpreterRuntimeError("Only data fields may be assigned.", node)
}
if (reference.receiver !== reference.target) {
throw typeError(
throw new InterpreterRuntimeError(
`Cannot create property '${String(reference.key)}' on ${typeof reference.receiver} '${String(reference.receiver)}'.`,
node,
)
@@ -2115,13 +2107,14 @@ class Frame<R> {
}
private assignToReference(target: ProgramObject, key: PropertyKey, next: unknown, node: AstNode): void {
rejectCircularInsertion(
target,
next,
target instanceof ProgramArray ? "Array assignment result" : "Object assignment result",
node,
)
const written = (() => {
try {
rejectCircularInsertion(
target,
next,
target instanceof ProgramArray ? "Array assignment result" : "Object assignment result",
)
return set(target, key, next)
} catch (error) {
throw locate(error, node)
@@ -2129,7 +2122,7 @@ class Frame<R> {
})()
if (written) return
if (target instanceof ProgramArray && key === "length") throw rangeError("Invalid array length", node)
throw typeError(`Cannot assign to read only property '${String(key)}'.`, node)
throw new InterpreterRuntimeError(`Cannot assign to read only property '${String(key)}'.`, node)
}
private toPropertyKey(value: unknown, node: AstNode): PropertyKey {
@@ -2138,6 +2131,9 @@ class Frame<R> {
}
if (value === AsyncIteratorSymbol || value === IteratorSymbol) return value
throw typeError("Property key must be a string or number, or Symbol.asyncIterator/Symbol.iterator.", node)
throw new InterpreterRuntimeError(
"Property key must be a string or number, or Symbol.asyncIterator/Symbol.iterator.",
node,
)
}
}
+6 -6
View File
@@ -1,4 +1,4 @@
import { type AstNode, type Binding, referenceError, typeError } from "./model.js"
import { type AstNode, type Binding, InterpreterRuntimeError, referenceError } from "./model.js"
export class ScopeStack {
private readonly scopes: Array<Map<string, Binding>>
@@ -10,7 +10,7 @@ export class ScopeStack {
reserve(name: string, mutable: boolean, node: AstNode): void {
const scope = this.current()
if (scope.has(name)) {
throw typeError(`Identifier '${name}' has already been declared.`, node)
throw new InterpreterRuntimeError(`Identifier '${name}' has already been declared.`, node)
}
scope.set(name, { mutable, value: undefined, initialized: false })
}
@@ -18,7 +18,7 @@ export class ScopeStack {
initialize(name: string, value: unknown, node: AstNode): void {
const binding = this.current().get(name)
if (!binding || binding.initialized !== false) {
throw typeError(`Identifier '${name}' has not been reserved for initialization.`, node)
throw new InterpreterRuntimeError(`Identifier '${name}' has not been reserved for initialization.`, node)
}
binding.value = value
binding.initialized = true
@@ -27,7 +27,7 @@ export class ScopeStack {
declare(name: string, value: unknown, mutable: boolean, node: AstNode): void {
const scope = this.current()
if (scope.has(name)) {
throw typeError(`Identifier '${name}' has already been declared.`, node)
throw new InterpreterRuntimeError(`Identifier '${name}' has already been declared.`, node)
}
scope.set(name, { mutable, value, initialized: true })
}
@@ -58,7 +58,7 @@ export class ScopeStack {
}
if (!binding.mutable) {
throw typeError(`Cannot assign to constant '${name}'.`, node)
throw new InterpreterRuntimeError(`Cannot assign to constant '${name}'.`, node)
}
binding.value = value
@@ -82,7 +82,7 @@ export class ScopeStack {
const scope = this.scopes[this.scopes.length - 1]
if (!scope) {
throw typeError("Interpreter scope stack is empty.")
throw new InterpreterRuntimeError("Interpreter scope stack is empty.")
}
return scope
+101 -89
View File
@@ -1,6 +1,6 @@
import { Effect } from "effect"
import { constructor, type Method, methods, prototypeFrom, receiver } from "../interpreter/native.js"
import { invalidData, rangeError, typeError } from "../interpreter/model.js"
import { type AstNode, InterpreterRuntimeError, rangeError } from "../interpreter/model.js"
import { get, ProgramArray, ProgramGenerator, ProgramObject } from "../interpreter/objects.js"
import { describeValue, rejectCircularInsertion } from "../interpreter/references.js"
import { applyCollectionCallback, preserveConsumerError, type Runner } from "../interpreter/runner.js"
@@ -9,30 +9,35 @@ import { coerceToNumber, coerceToString } from "./value.js"
const MAX_LENGTH = 4_294_967_295
const arrayLikeSource = (source: unknown): { readonly length: number; readonly source: ProgramObject } => {
const arrayLikeSource = (
source: unknown,
node: AstNode,
): { readonly length: number; readonly source: ProgramObject } => {
if (source instanceof ProgramObject && typeof get(source, "length") === "number") {
const length = get(source, "length") as number
const normalized = Number.isNaN(length) || length <= 0 ? 0 : Math.trunc(length)
if (normalized > MAX_LENGTH) throw new RangeError("Invalid array length")
return { length: normalized, source }
}
throw invalidData(
throw new InterpreterRuntimeError(
`Array.from expects an array, string, Map, Set, or array-like value, received ${describeValue(source)}.`,
node,
"InvalidDataValue",
)
}
const arrayFrom = <R>(runner: Runner<R>, args: Array<unknown>): Effect.Effect<unknown, unknown, R> => {
const arrayFrom = <R>(runner: Runner<R>, args: Array<unknown>, node: AstNode): Effect.Effect<unknown, unknown, R> => {
const source = args[0]
const proto = runner.prototypes.Array
const apply =
args.length < 2 || args[1] === undefined ? undefined : applyCollectionCallback(runner, args[1], "Array.from")
args.length < 2 || args[1] === undefined ? undefined : applyCollectionCallback(runner, args[1], "Array.from", node)
return Effect.gen(function* () {
const cursor = yield* runner.syncIterator(source)
const cursor = yield* runner.syncIterator(source, node)
if (cursor === undefined) {
if (source instanceof ProgramGenerator) {
throw typeError("Array.from expects a synchronous iterable or array-like value.")
throw new InterpreterRuntimeError("Array.from expects a synchronous iterable or array-like value.", node)
}
const arrayLike = arrayLikeSource(source)
const arrayLike = arrayLikeSource(source, node)
const values: Array<unknown> = []
for (let index = 0; index < arrayLike.length; index += 1) {
const item = get(arrayLike.source, index)
@@ -56,11 +61,12 @@ export const sortArray = <R>(
target: Array<unknown>,
comparator: unknown,
name: string,
node: AstNode,
): Effect.Effect<Array<unknown>, unknown, R> => {
if (comparator === undefined) {
return Effect.sync(() => [...target].sort((a, b) => compareText(coerceToString(a), coerceToString(b))))
}
const apply = applyCollectionCallback(runner, comparator, name)
const apply = applyCollectionCallback(runner, comparator, name, node)
const mergeSort = (items: Array<unknown>): Effect.Effect<Array<unknown>, unknown, R> => {
if (items.length <= 1) return Effect.succeed(items)
const midpoint = Math.floor(items.length / 2)
@@ -89,31 +95,32 @@ export const arrayGlobal = <R>(runner: Runner<R>) => {
const protos = runner.prototypes
const proto = protos.Array
const wrap = (items: Array<unknown>) => new ProgramArray(proto, items)
const construct = (args: Array<unknown>, into: ProgramObject): ProgramArray => {
const construct = (args: Array<unknown>, into: ProgramObject, node: AstNode): ProgramArray => {
if (args.length !== 1) return new ProgramArray(into, [...args])
const first = args[0]
if (typeof first !== "number") return new ProgramArray(into, [first])
if (!Number.isInteger(first) || first < 0 || first > MAX_LENGTH) throw rangeError("Invalid array length.")
if (!Number.isInteger(first) || first < 0 || first > MAX_LENGTH) throw rangeError("Invalid array length.", node)
// Sparse like JS: Array(3) has holes, and combinator loops already skip them.
return new ProgramArray(into, new Array(first))
}
const array = constructor<R>(protos, proto, {
name: "Array",
length: 1,
call: (_, args) => Effect.sync(() => construct(args, proto)),
construct: (args, newTarget) => Effect.sync(() => construct(args, prototypeFrom(newTarget, proto))),
call: (_, args, node) => Effect.sync(() => construct(args, proto, node)),
construct: (args, newTarget, node) => Effect.sync(() => construct(args, prototypeFrom(newTarget, proto), node)),
})
methods(protos, array, [
["isArray", 1, (_, args) => args[0] instanceof ProgramArray],
["of", 0, (_, args) => wrap([...args])],
["from", 1, (_, args) => arrayFrom(runner, args)],
["from", 1, (_, args, node) => arrayFrom(runner, args, node)],
])
const self = (thisValue: unknown, name: string) => receiver(ProgramArray, thisValue, `Array.prototype.${name}`)
const optNumber = (name: string, value: unknown, label: string): number | undefined => {
const self = (thisValue: unknown, name: string, node: AstNode) =>
receiver(ProgramArray, thisValue, `Array.prototype.${name}`, node)
const optNumber = (name: string, value: unknown, label: string, node: AstNode): number | undefined => {
if (value === undefined) return undefined
if (typeof value !== "number") {
throw typeError(`Array.${name} expects ${label} to be a number.`)
throw new InterpreterRuntimeError(`Array.${name} expects ${label} to be a number.`, node)
}
return value
}
@@ -126,13 +133,14 @@ export const arrayGlobal = <R>(runner: Runner<R>) => {
receiver: ProgramArray,
apply: (args: Array<unknown>) => Effect.Effect<unknown, unknown, R>,
args: Array<unknown>,
node: AstNode,
) => Effect.Effect<unknown, unknown, R>,
): Method => [
name,
length,
(thisValue, args) => {
const target = self(thisValue, name)
return body(target.items, target, applyCollectionCallback(runner, args[0], `Array.${name}`), args)
(thisValue, args, node) => {
const target = self(thisValue, name, node)
return body(target.items, target, applyCollectionCallback(runner, args[0], `Array.${name}`, node), args, node)
},
]
@@ -140,10 +148,10 @@ export const arrayGlobal = <R>(runner: Runner<R>) => {
[
"join",
1,
(thisValue, args) => {
const target = self(thisValue, "join").items
(thisValue, args, node) => {
const target = self(thisValue, "join", node).items
if (args.length > 1 || (args.length === 1 && typeof args[0] !== "string")) {
throw typeError("Array.join expects zero arguments or one string separator.")
throw new InterpreterRuntimeError("Array.join expects zero arguments or one string separator.", node)
}
return target.map((item) => coerceToString(item ?? "")).join(args.length === 0 ? "," : (args[0] as string))
},
@@ -151,56 +159,60 @@ export const arrayGlobal = <R>(runner: Runner<R>) => {
[
"toString",
0,
(thisValue) =>
self(thisValue, "toString")
(thisValue, _, node) =>
self(thisValue, "toString", node)
.items.map((item) => coerceToString(item ?? ""))
.join(","),
],
[
"includes",
1,
(thisValue, args) => {
const target = self(thisValue, "includes").items
(thisValue, args, node) => {
const target = self(thisValue, "includes", node).items
if (args.length === 0 || args.length > 2) {
throw typeError("Array.includes expects a value and optional start index.")
throw new InterpreterRuntimeError("Array.includes expects a value and optional start index.", node)
}
return target.includes(args[0], optNumber("includes", args[1], "start index"))
return target.includes(args[0], optNumber("includes", args[1], "start index", node))
},
],
[
"indexOf",
1,
(thisValue, args) =>
self(thisValue, "indexOf").items.indexOf(args[0], optNumber("indexOf", args[1], "start index")),
(thisValue, args, node) =>
self(thisValue, "indexOf", node).items.indexOf(args[0], optNumber("indexOf", args[1], "start index", node)),
],
[
"lastIndexOf",
1,
(thisValue, args) => {
const target = self(thisValue, "lastIndexOf").items
(thisValue, args, node) => {
const target = self(thisValue, "lastIndexOf", node).items
return args[1] === undefined
? target.lastIndexOf(args[0])
: target.lastIndexOf(args[0], optNumber("lastIndexOf", args[1], "start index"))
: target.lastIndexOf(args[0], optNumber("lastIndexOf", args[1], "start index", node))
},
],
["at", 1, (thisValue, args) => self(thisValue, "at").items.at(optNumber("at", args[0], "index") ?? 0)],
[
"at",
1,
(thisValue, args, node) => self(thisValue, "at", node).items.at(optNumber("at", args[0], "index", node) ?? 0),
],
[
"slice",
2,
(thisValue, args) =>
(thisValue, args, node) =>
wrap(
self(thisValue, "slice").items.slice(
optNumber("slice", args[0], "start"),
optNumber("slice", args[1], "end"),
self(thisValue, "slice", node).items.slice(
optNumber("slice", args[0], "start", node),
optNumber("slice", args[1], "end", node),
),
),
],
[
"concat",
1,
(thisValue, args) =>
(thisValue, args, node) =>
wrap(
self(thisValue, "concat").items.concat(
self(thisValue, "concat", node).items.concat(
...args.map((item) => (item instanceof ProgramArray ? item.items : item)),
),
),
@@ -208,17 +220,17 @@ export const arrayGlobal = <R>(runner: Runner<R>) => {
[
"flat",
0,
(thisValue, args) => {
(thisValue, args, node) => {
const flatten = (items: Array<unknown>, depth: number): Array<unknown> =>
items.flatMap((item) => (item instanceof ProgramArray && depth > 0 ? flatten(item.items, depth - 1) : [item]))
return wrap(flatten(self(thisValue, "flat").items, optNumber("flat", args[0], "depth") ?? 1))
return wrap(flatten(self(thisValue, "flat", node).items, optNumber("flat", args[0], "depth", node) ?? 1))
},
],
[
"reverse",
0,
(thisValue) => {
const target = self(thisValue, "reverse")
(thisValue, _, node) => {
const target = self(thisValue, "reverse", node)
target.items.reverse()
return target
},
@@ -226,13 +238,13 @@ export const arrayGlobal = <R>(runner: Runner<R>) => {
[
"sort",
1,
(thisValue, args) => {
const target = self(thisValue, "sort")
(thisValue, args, node) => {
const target = self(thisValue, "sort", node)
const items = target.items
const length = items.length
const holeCount = Array.from({ length }, (_, index) => Object.hasOwn(items, index)).filter((o) => !o).length
const itemCount = length - holeCount
return Effect.map(sortArray(runner, items, args[0], "Array.sort"), (sorted) => {
return Effect.map(sortArray(runner, items, args[0], "Array.sort", node), (sorted) => {
sorted.slice(0, itemCount).forEach((item, index) => {
items[index] = item
})
@@ -246,18 +258,18 @@ export const arrayGlobal = <R>(runner: Runner<R>) => {
[
"toSorted",
1,
(thisValue, args) =>
Effect.map(sortArray(runner, self(thisValue, "toSorted").items, args[0], "Array.toSorted"), wrap),
(thisValue, args, node) =>
Effect.map(sortArray(runner, self(thisValue, "toSorted", node).items, args[0], "Array.toSorted", node), wrap),
],
["toReversed", 0, (thisValue) => wrap([...self(thisValue, "toReversed").items].reverse())],
["toReversed", 0, (thisValue, _, node) => wrap([...self(thisValue, "toReversed", node).items].reverse())],
[
"with",
2,
(thisValue, args) => {
const target = self(thisValue, "with").items
const index = optNumber("with", args[0], "index") ?? 0
(thisValue, args, node) => {
const target = self(thisValue, "with", node).items
const index = optNumber("with", args[0], "index", node) ?? 0
const resolved = index < 0 ? target.length + index : index
if (resolved < 0 || resolved >= target.length) throw rangeError("Array.with index is out of range.")
if (resolved < 0 || resolved >= target.length) throw rangeError("Array.with index is out of range.", node)
const copied = [...target]
copied[resolved] = args[1]
return wrap(copied)
@@ -266,80 +278,80 @@ export const arrayGlobal = <R>(runner: Runner<R>) => {
[
"push",
1,
(thisValue, args) => {
const target = self(thisValue, "push")
(thisValue, args, node) => {
const target = self(thisValue, "push", node)
// Validate all insertions before mutating to avoid partial cyclic updates.
for (const item of args) rejectCircularInsertion(target, item, "Array.push result")
for (const item of args) rejectCircularInsertion(target, item, "Array.push result", node)
return target.items.push(...args)
},
],
[
"unshift",
1,
(thisValue, args) => {
const target = self(thisValue, "unshift")
for (const item of args) rejectCircularInsertion(target, item, "Array.unshift result")
(thisValue, args, node) => {
const target = self(thisValue, "unshift", node)
for (const item of args) rejectCircularInsertion(target, item, "Array.unshift result", node)
return target.items.unshift(...args)
},
],
["pop", 0, (thisValue) => self(thisValue, "pop").items.pop()],
["shift", 0, (thisValue) => self(thisValue, "shift").items.shift()],
["pop", 0, (thisValue, _, node) => self(thisValue, "pop", node).items.pop()],
["shift", 0, (thisValue, _, node) => self(thisValue, "shift", node).items.shift()],
[
"splice",
2,
(thisValue, args) => {
const target = self(thisValue, "splice")
(thisValue, args, node) => {
const target = self(thisValue, "splice", node)
if (args.length === 0) return wrap(target.items.splice(0, 0))
const start = optNumber("splice", args[0], "start") ?? 0
const start = optNumber("splice", args[0], "start", node) ?? 0
if (args.length === 1) return wrap(target.items.splice(start))
const deleteCount = optNumber("splice", args[1], "delete count") ?? 0
const deleteCount = optNumber("splice", args[1], "delete count", node) ?? 0
const inserted = args.slice(2)
for (const item of inserted) rejectCircularInsertion(target, item, "Array.splice result")
for (const item of inserted) rejectCircularInsertion(target, item, "Array.splice result", node)
return wrap(target.items.splice(start, deleteCount, ...inserted))
},
],
[
"toSpliced",
2,
(thisValue, args) => {
const copied = [...self(thisValue, "toSpliced").items]
(thisValue, args, node) => {
const copied = [...self(thisValue, "toSpliced", node).items]
if (args.length === 0) return wrap(copied)
const start = optNumber("toSpliced", args[0], "start") ?? 0
const start = optNumber("toSpliced", args[0], "start", node) ?? 0
if (args.length === 1) copied.splice(start)
else copied.splice(start, optNumber("toSpliced", args[1], "delete count") ?? 0, ...args.slice(2))
else copied.splice(start, optNumber("toSpliced", args[1], "delete count", node) ?? 0, ...args.slice(2))
return wrap(copied)
},
],
[
"fill",
1,
(thisValue, args) => {
const target = self(thisValue, "fill")
rejectCircularInsertion(target, args[0], "Array.fill result")
target.items.fill(args[0], optNumber("fill", args[1], "start"), optNumber("fill", args[2], "end"))
(thisValue, args, node) => {
const target = self(thisValue, "fill", node)
rejectCircularInsertion(target, args[0], "Array.fill result", node)
target.items.fill(args[0], optNumber("fill", args[1], "start", node), optNumber("fill", args[2], "end", node))
return target
},
],
[
"copyWithin",
2,
(thisValue, args) => {
const target = self(thisValue, "copyWithin")
(thisValue, args, node) => {
const target = self(thisValue, "copyWithin", node)
target.items.copyWithin(
optNumber("copyWithin", args[0], "target index") ?? 0,
optNumber("copyWithin", args[1], "start") ?? 0,
optNumber("copyWithin", args[2], "end"),
optNumber("copyWithin", args[0], "target index", node) ?? 0,
optNumber("copyWithin", args[1], "start", node) ?? 0,
optNumber("copyWithin", args[2], "end", node),
)
return target
},
],
["keys", 0, (thisValue) => wrap(Array.from(self(thisValue, "keys").items.keys()))],
["values", 0, (thisValue) => wrap([...self(thisValue, "values").items])],
["keys", 0, (thisValue, _, node) => wrap(Array.from(self(thisValue, "keys", node).items.keys()))],
["values", 0, (thisValue, _, node) => wrap([...self(thisValue, "values", node).items])],
[
"entries",
0,
(thisValue) =>
wrap(Array.from(self(thisValue, "entries").items.entries(), ([index, item]) => wrap([index, item]))),
(thisValue, _, node) =>
wrap(Array.from(self(thisValue, "entries", node).items.entries(), ([index, item]) => wrap([index, item]))),
],
iterate("map", 1, (target, receiver, apply) =>
Effect.gen(function* () {
@@ -443,7 +455,7 @@ export const arrayGlobal = <R>(runner: Runner<R>) => {
return undefined
}),
),
iterate("reduce", 1, (target, receiver, apply, args) =>
iterate("reduce", 1, (target, receiver, apply, args, node) =>
Effect.gen(function* () {
const length = target.length
let start = 0
@@ -451,7 +463,7 @@ export const arrayGlobal = <R>(runner: Runner<R>) => {
if (args.length < 2) {
while (start < length && !(start in target)) start += 1
if (start === length) {
throw typeError("Array.reduce of an empty array with no initial value.")
throw new InterpreterRuntimeError("Array.reduce of an empty array with no initial value.", node)
}
accumulator = target[start]
start += 1
@@ -463,14 +475,14 @@ export const arrayGlobal = <R>(runner: Runner<R>) => {
return accumulator
}),
),
iterate("reduceRight", 1, (target, receiver, apply, args) =>
iterate("reduceRight", 1, (target, receiver, apply, args, node) =>
Effect.gen(function* () {
let start = target.length - 1
let accumulator = args[1]
if (args.length < 2) {
while (start >= 0 && !(start in target)) start -= 1
if (start < 0) {
throw typeError("Array.reduceRight of an empty array with no initial value.")
throw new InterpreterRuntimeError("Array.reduceRight of an empty array with no initial value.", node)
}
accumulator = target[start]
start -= 1
+69 -55
View File
@@ -1,6 +1,6 @@
import { Effect } from "effect"
import { constructor, fn, type Method, methods, prototypeFrom, receiver, requiresNew } from "../interpreter/native.js"
import { invalidData, typeError } from "../interpreter/model.js"
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import {
define,
defineAccessor,
@@ -24,27 +24,35 @@ import {
toPrimitiveString,
} from "../interpreter/runner.js"
const coerceGroupByPropertyKey = <R>(runner: Runner<R>, value: unknown): Effect.Effect<string, unknown, R> => {
const coerceGroupByPropertyKey = <R>(
runner: Runner<R>,
value: unknown,
node: AstNode,
): Effect.Effect<string, unknown, R> => {
if (value instanceof ProgramPromise) return Effect.succeed("[object Promise]")
if (!isWrapper(value) && isRuntimeReference(value)) {
throw invalidData(`Object.groupBy callback must return a data value, received ${describeValue(value)}.`)
throw new InterpreterRuntimeError(
`Object.groupBy callback must return a data value, received ${describeValue(value)}.`,
node,
"InvalidDataValue",
)
}
return toPrimitiveString(runner, value)
return toPrimitiveString(runner, value, node)
}
/** `Map.groupBy` and `Object.groupBy`: the same iteration, keyed into a Map or a data object. */
export const groupBy = <R>(runner: Runner<R>, namespace: "Map" | "Object") =>
fn<R>(runner.prototypes, "groupBy", 2, (_, args) => {
fn<R>(runner.prototypes, "groupBy", 2, (_, args, node) => {
const protos = runner.prototypes
const source = args[0]
if (source === null || source === undefined) {
throw typeError(`${namespace}.groupBy expects an iterable collection.`)
throw new InterpreterRuntimeError(`${namespace}.groupBy expects an iterable collection.`, node)
}
const apply = applyCollectionCallback(runner, args[1], `${namespace}.groupBy`)
const apply = applyCollectionCallback(runner, args[1], `${namespace}.groupBy`, node)
return Effect.gen(function* () {
const cursor = yield* runner.syncIterator(source)
const cursor = yield* runner.syncIterator(source, node)
if (cursor === undefined) {
throw typeError(`${namespace}.groupBy expects an iterable collection.`)
throw new InterpreterRuntimeError(`${namespace}.groupBy expects an iterable collection.`, node)
}
if (namespace === "Map") {
const result = new ProgramMap(protos.Map)
@@ -70,7 +78,7 @@ export const groupBy = <R>(runner: Runner<R>, namespace: "Map" | "Object") =>
const item = step.value
const key = yield* preserveConsumerError(
cursor,
Effect.flatMap(apply([item, index]), (value) => coerceGroupByPropertyKey(runner, value)),
Effect.flatMap(apply([item, index]), (value) => coerceGroupByPropertyKey(runner, value, node)),
)
const group = getOwn(result, key)
if (group === undefined) define(result, key, new ProgramArray(protos.Array, [item]))
@@ -80,13 +88,13 @@ export const groupBy = <R>(runner: Runner<R>, namespace: "Map" | "Object") =>
})
})
const constructMap = <R>(runner: Runner<R>, init: unknown, proto: ProgramObject) => {
const constructMap = <R>(runner: Runner<R>, init: unknown, proto: ProgramObject, node: AstNode) => {
const target = new ProgramMap(proto)
if (init === undefined || init === null) return Effect.succeed(target)
return Effect.gen(function* () {
const cursor = yield* runner.syncIterator(init)
const cursor = yield* runner.syncIterator(init, node)
if (cursor === undefined) {
throw typeError("new Map(...) expects an iterable of [key, value] pairs or no argument.")
throw new InterpreterRuntimeError("new Map(...) expects an iterable of [key, value] pairs or no argument.", node)
}
while (true) {
const step = yield* cursor.next
@@ -95,7 +103,7 @@ const constructMap = <R>(runner: Runner<R>, init: unknown, proto: ProgramObject)
cursor,
Effect.sync(() => {
if (!(step.value instanceof ProgramObject)) {
throw typeError("new Map(...) expects [key, value] pairs as entry objects.")
throw new InterpreterRuntimeError("new Map(...) expects [key, value] pairs as entry objects.", node)
}
target.map.set(getOwn(step.value, 0), getOwn(step.value, 1))
}),
@@ -104,13 +112,13 @@ const constructMap = <R>(runner: Runner<R>, init: unknown, proto: ProgramObject)
})
}
const constructSet = <R>(runner: Runner<R>, init: unknown, proto: ProgramObject) => {
const constructSet = <R>(runner: Runner<R>, init: unknown, proto: ProgramObject, node: AstNode) => {
const target = new ProgramSet(proto)
if (init === undefined || init === null) return Effect.succeed(target)
return Effect.gen(function* () {
const cursor = yield* runner.syncIterator(init)
const cursor = yield* runner.syncIterator(init, node)
if (cursor === undefined) {
throw typeError("new Set(...) expects a synchronous iterable or no argument.")
throw new InterpreterRuntimeError("new Set(...) expects a synchronous iterable or no argument.", node)
}
while (true) {
const step = yield* cursor.next
@@ -126,46 +134,48 @@ export const mapGlobal = <R>(runner: Runner<R>) => {
const map = constructor<R>(protos, proto, {
name: "Map",
call: requiresNew("Map"),
construct: (args, newTarget) => constructMap(runner, args[0], prototypeFrom(newTarget, proto)),
construct: (args, newTarget, node) => constructMap(runner, args[0], prototypeFrom(newTarget, proto), node),
})
define(map, "groupBy", groupBy(runner, "Map"), hidden)
const self = (thisValue: unknown, name: string) => receiver(ProgramMap, thisValue, `Map.prototype.${name}`)
const self = (thisValue: unknown, name: string, node: AstNode) =>
receiver(ProgramMap, thisValue, `Map.prototype.${name}`, node)
const wrap = (items: Array<unknown>) => new ProgramArray(protos.Array, items)
defineAccessor(proto, "size", (thisValue) => receiver(ProgramMap, thisValue, "Map.prototype.size").map.size)
methods(protos, proto, [
["get", 1, (thisValue, args) => self(thisValue, "get").map.get(args[0])],
["has", 1, (thisValue, args) => self(thisValue, "has").map.has(args[0])],
["get", 1, (thisValue, args, node) => self(thisValue, "get", node).map.get(args[0])],
["has", 1, (thisValue, args, node) => self(thisValue, "has", node).map.has(args[0])],
[
"set",
2,
(thisValue, args) => {
const target = self(thisValue, "set")
(thisValue, args, node) => {
const target = self(thisValue, "set", node)
target.map.set(args[0], args[1])
return target
},
],
["delete", 1, (thisValue, args) => self(thisValue, "delete").map.delete(args[0])],
["delete", 1, (thisValue, args, node) => self(thisValue, "delete", node).map.delete(args[0])],
[
"clear",
0,
(thisValue) => {
self(thisValue, "clear").map.clear()
(thisValue, _, node) => {
self(thisValue, "clear", node).map.clear()
return undefined
},
],
["keys", 0, (thisValue) => wrap(Array.from(self(thisValue, "keys").map.keys()))],
["values", 0, (thisValue) => wrap(Array.from(self(thisValue, "values").map.values()))],
["keys", 0, (thisValue, _, node) => wrap(Array.from(self(thisValue, "keys", node).map.keys()))],
["values", 0, (thisValue, _, node) => wrap(Array.from(self(thisValue, "values", node).map.values()))],
[
"entries",
0,
(thisValue) => wrap(Array.from(self(thisValue, "entries").map.entries(), ([key, item]) => wrap([key, item]))),
(thisValue, _, node) =>
wrap(Array.from(self(thisValue, "entries", node).map.entries(), ([key, item]) => wrap([key, item]))),
],
[
"forEach",
1,
(thisValue, args) => {
const target = self(thisValue, "forEach")
const apply = applyCollectionCallback(runner, args[0], "Map.forEach")
(thisValue, args, node) => {
const target = self(thisValue, "forEach", node)
const apply = applyCollectionCallback(runner, args[0], "Map.forEach", node)
return Effect.gen(function* () {
for (const [key, item] of Array.from(target.map.entries())) yield* apply([item, key, target])
return undefined
@@ -186,6 +196,7 @@ const loadSetRecord = <R>(
runner: Runner<R>,
source: unknown,
name: string,
node: AstNode,
): Effect.Effect<SetRecord<R>, unknown, R> => {
if (source instanceof ProgramSet) {
return Effect.succeed({
@@ -202,25 +213,25 @@ const loadSetRecord = <R>(
})
}
if (!(source instanceof ProgramObject)) {
throw typeError(`Set.${name} expects a Set-like object.`)
throw new InterpreterRuntimeError(`Set.${name} expects a Set-like object.`, node)
}
return Effect.gen(function* () {
const size = yield* toPrimitiveNumber(runner, get(source, "size"))
const size = yield* toPrimitiveNumber(runner, get(source, "size"), node)
if (Number.isNaN(size)) {
throw typeError(`Set.${name} received a Set-like object with an invalid size.`)
throw new InterpreterRuntimeError(`Set.${name} received a Set-like object with an invalid size.`, node)
}
const has = get(source, "has")
const keys = get(source, "keys")
if (!isSupportedCallback(has) || !isSupportedCallback(keys)) {
throw typeError(`Set.${name} expects callable 'has' and 'keys' methods.`)
throw new InterpreterRuntimeError(`Set.${name} expects callable 'has' and 'keys' methods.`, node)
}
return {
size: Math.max(Math.trunc(size), 0),
has: (item: unknown) => Effect.map(runner.invokeCallable(has, source, [item]), Boolean),
has: (item: unknown) => Effect.map(runner.invokeCallable(has, source, [item], node), Boolean),
keys: () =>
Effect.flatMap(runner.invokeCallable(keys, source, []), (result) => {
Effect.flatMap(runner.invokeCallable(keys, source, [], node), (result) => {
if (result instanceof ProgramArray) return Effect.succeed(result.items)
throw typeError(`Set.${name} expected 'keys' to return an iterator.`)
throw new InterpreterRuntimeError(`Set.${name} expected 'keys' to return an iterator.`, node)
}),
}
})
@@ -231,9 +242,10 @@ const setOperation = <R>(
target: ProgramSet,
name: string,
source: unknown,
node: AstNode,
): Effect.Effect<unknown, unknown, R> =>
Effect.gen(function* () {
const other = yield* loadSetRecord(runner, source, name)
const other = yield* loadSetRecord(runner, source, name, node)
const copy = () => {
const result = new ProgramSet(runner.prototypes.Set)
for (const item of target.set.values()) result.set.add(item)
@@ -308,49 +320,51 @@ export const setGlobal = <R>(runner: Runner<R>) => {
const set = constructor<R>(protos, proto, {
name: "Set",
call: requiresNew("Set"),
construct: (args, newTarget) => constructSet(runner, args[0], prototypeFrom(newTarget, proto)),
construct: (args, newTarget, node) => constructSet(runner, args[0], prototypeFrom(newTarget, proto), node),
})
const self = (thisValue: unknown, name: string) => receiver(ProgramSet, thisValue, `Set.prototype.${name}`)
const self = (thisValue: unknown, name: string, node: AstNode) =>
receiver(ProgramSet, thisValue, `Set.prototype.${name}`, node)
const wrap = (items: Array<unknown>) => new ProgramArray(protos.Array, items)
const operation = (name: string): Method => [
name,
1,
(thisValue, args) => setOperation(runner, self(thisValue, name), name, args[0]),
(thisValue, args, node) => setOperation(runner, self(thisValue, name, node), name, args[0], node),
]
defineAccessor(proto, "size", (thisValue) => receiver(ProgramSet, thisValue, "Set.prototype.size").set.size)
methods(protos, proto, [
["has", 1, (thisValue, args) => self(thisValue, "has").set.has(args[0])],
["has", 1, (thisValue, args, node) => self(thisValue, "has", node).set.has(args[0])],
[
"add",
1,
(thisValue, args) => {
const target = self(thisValue, "add")
(thisValue, args, node) => {
const target = self(thisValue, "add", node)
target.set.add(args[0])
return target
},
],
["delete", 1, (thisValue, args) => self(thisValue, "delete").set.delete(args[0])],
["delete", 1, (thisValue, args, node) => self(thisValue, "delete", node).set.delete(args[0])],
[
"clear",
0,
(thisValue) => {
self(thisValue, "clear").set.clear()
(thisValue, _, node) => {
self(thisValue, "clear", node).set.clear()
return undefined
},
],
["keys", 0, (thisValue) => wrap(Array.from(self(thisValue, "keys").set.values()))],
["values", 0, (thisValue) => wrap(Array.from(self(thisValue, "values").set.values()))],
["keys", 0, (thisValue, _, node) => wrap(Array.from(self(thisValue, "keys", node).set.values()))],
["values", 0, (thisValue, _, node) => wrap(Array.from(self(thisValue, "values", node).set.values()))],
[
"entries",
0,
(thisValue) => wrap(Array.from(self(thisValue, "entries").set.values(), (item) => wrap([item, item]))),
(thisValue, _, node) =>
wrap(Array.from(self(thisValue, "entries", node).set.values(), (item) => wrap([item, item]))),
],
[
"forEach",
1,
(thisValue, args) => {
const target = self(thisValue, "forEach")
const apply = applyCollectionCallback(runner, args[0], "Set.forEach")
(thisValue, args, node) => {
const target = self(thisValue, "forEach", node)
const apply = applyCollectionCallback(runner, args[0], "Set.forEach", node)
return Effect.gen(function* () {
for (const item of Array.from(target.set.values())) yield* apply([item, item, target])
return undefined
+25 -22
View File
@@ -1,16 +1,16 @@
import { Effect } from "effect"
import { constructor, type Method, methods, prototypeFrom, receiver } from "../interpreter/native.js"
import { rangeError } from "../interpreter/model.js"
import { type AstNode, rangeError } from "../interpreter/model.js"
import { ProgramDate, ProgramObject } from "../interpreter/objects.js"
import { type Runner, toPrimitive, toPrimitiveNumber } from "../interpreter/runner.js"
import { coerceToNumber, coerceToString } from "./value.js"
const constructDate = <R>(runner: Runner<R>, args: Array<unknown>, proto: ProgramObject) => {
const constructDate = <R>(runner: Runner<R>, args: Array<unknown>, proto: ProgramObject, node: AstNode) => {
if (args.length === 0) return Effect.succeed(new ProgramDate(proto, Date.now()))
if (args.length === 1) {
const arg = args[0]
if (arg instanceof ProgramDate) return Effect.succeed(new ProgramDate(proto, arg.time))
return Effect.map(toPrimitive(runner, arg, "default"), (value) =>
return Effect.map(toPrimitive(runner, arg, "default", node), (value) =>
typeof value === "string"
? new ProgramDate(proto, Date.parse(value))
: new ProgramDate(proto, new Date(coerceToNumber(value)).getTime()),
@@ -74,7 +74,7 @@ export const dateGlobal = <R>(runner: Runner<R>) => {
length: 7,
// ISO instead of the host's locale string: date strings are deterministic and must not leak the host timezone.
call: () => Effect.sync(() => new Date().toISOString()),
construct: (args, newTarget) => constructDate(runner, args, prototypeFrom(newTarget, proto)),
construct: (args, newTarget, node) => constructDate(runner, args, prototypeFrom(newTarget, proto), node),
})
methods(protos, date, [
["now", 0, () => Date.now()],
@@ -82,39 +82,42 @@ export const dateGlobal = <R>(runner: Runner<R>) => {
["UTC", 7, (_, args) => Date.UTC(...(args.map((arg) => coerceToNumber(arg)) as Parameters<typeof Date.UTC>))],
])
const self = (thisValue: unknown, name: string) => receiver(ProgramDate, thisValue, `Date.prototype.${name}`)
const iso = (value: ProgramDate) => {
if (!Number.isFinite(value.time)) throw rangeError("Invalid time value.")
const self = (thisValue: unknown, name: string, node: AstNode) =>
receiver(ProgramDate, thisValue, `Date.prototype.${name}`, node)
const iso = (value: ProgramDate, node: AstNode) => {
if (!Number.isFinite(value.time)) throw rangeError("Invalid time value.", node)
return new Date(value.time).toISOString()
}
methods(protos, proto, [
["getTime", 0, (thisValue) => self(thisValue, "getTime").time],
["valueOf", 0, (thisValue) => self(thisValue, "valueOf").time],
["toISOString", 0, (thisValue) => iso(self(thisValue, "toISOString"))],
["getTime", 0, (thisValue, _, node) => self(thisValue, "getTime", node).time],
["valueOf", 0, (thisValue, _, node) => self(thisValue, "valueOf", node).time],
["toISOString", 0, (thisValue, _, node) => iso(self(thisValue, "toISOString", node), node)],
[
"toJSON",
1,
(thisValue) => {
const value = self(thisValue, "toJSON")
return Number.isFinite(value.time) ? iso(value) : null
(thisValue, _, node) => {
const value = self(thisValue, "toJSON", node)
return Number.isFinite(value.time) ? iso(value, node) : null
},
],
["toString", 0, (thisValue) => coerceToString(self(thisValue, "toString"))],
["toDateString", 0, (thisValue) => new Date(self(thisValue, "toDateString").time).toDateString()],
["toTimeString", 0, (thisValue) => new Date(self(thisValue, "toTimeString").time).toTimeString()],
["toUTCString", 0, (thisValue) => new Date(self(thisValue, "toUTCString").time).toUTCString()],
["toGMTString", 0, (thisValue) => new Date(self(thisValue, "toGMTString").time).toUTCString()],
...getters.map((name): Method => [name, 0, (thisValue) => new Date(self(thisValue, name).time)[name]()]),
["toString", 0, (thisValue, _, node) => coerceToString(self(thisValue, "toString", node))],
["toDateString", 0, (thisValue, _, node) => new Date(self(thisValue, "toDateString", node).time).toDateString()],
["toTimeString", 0, (thisValue, _, node) => new Date(self(thisValue, "toTimeString", node).time).toTimeString()],
["toUTCString", 0, (thisValue, _, node) => new Date(self(thisValue, "toUTCString", node).time).toUTCString()],
["toGMTString", 0, (thisValue, _, node) => new Date(self(thisValue, "toGMTString", node).time).toUTCString()],
...getters.map(
(name): Method => [name, 0, (thisValue, _, node) => new Date(self(thisValue, name, node).time)[name]()],
),
...setters.map(
([name, length]): Method => [
name,
length,
(thisValue, args) => {
const target = self(thisValue, name)
(thisValue, args, node) => {
const target = self(thisValue, name, node)
// Native setters read the current time before argument coercion, whose callbacks may mutate the Date.
const hosted = new Date(target.time)
return Effect.map(
Effect.forEach(args.slice(0, length), (arg) => toPrimitiveNumber(runner, arg), {
Effect.forEach(args.slice(0, length), (arg) => toPrimitiveNumber(runner, arg, node), {
concurrency: 1,
}),
(values) => {
+16 -13
View File
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import { methods } from "../interpreter/native.js"
import { applyCollectionCallback, type Runner } from "../interpreter/runner.js"
import { syntaxError, typeError } from "../interpreter/model.js"
import { type AstNode, InterpreterRuntimeError, syntaxError } from "../interpreter/model.js"
import { typeofValue } from "../interpreter/references.js"
import { fromData, toData, toProgram } from "../data.js"
import { Callable, get, keys, ProgramArray, ProgramObject, record, remove, set } from "../interpreter/objects.js"
@@ -9,26 +9,29 @@ import { Callable, get, keys, ProgramArray, ProgramObject, record, remove, set }
export const jsonGlobal = <R>(runner: Runner<R>) => {
const json = new ProgramObject(runner.prototypes.Object)
methods(runner.prototypes, json, [
["parse", 2, (_, args) => parse(runner, args)],
["stringify", 3, (_, args) => stringify(runner, args)],
["parse", 2, (_, args, node) => parse(runner, args, node)],
["stringify", 3, (_, args, node) => stringify(runner, args, node)],
])
return json
}
const parse = <R>(runner: Runner<R>, args: Array<unknown>): Effect.Effect<unknown, unknown, R> => {
const parse = <R>(runner: Runner<R>, args: Array<unknown>, node: AstNode): Effect.Effect<unknown, unknown, R> => {
const text = args[0]
if (typeof text !== "string") throw typeError("JSON.parse expects a string.")
if (typeof text !== "string") throw new InterpreterRuntimeError("JSON.parse expects a string.", node)
const parsed = (() => {
try {
return fromData(runner.prototypes, JSON.parse(text), "JSON.parse result")
} catch (error) {
throw syntaxError(`JSON.parse received invalid JSON: ${error instanceof Error ? error.message : String(error)}`)
throw syntaxError(
`JSON.parse received invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
node,
)
}
})()
if (typeofValue(args[1]) !== "function") return Effect.succeed(parsed)
const apply = applyCollectionCallback(runner, args[1], "JSON.parse")
const apply = applyCollectionCallback(runner, args[1], "JSON.parse", node)
const visit = (holder: ProgramObject, key: string): Effect.Effect<unknown, unknown, R> =>
Effect.gen(function* () {
const value = get(holder, key)
@@ -44,7 +47,7 @@ const parse = <R>(runner: Runner<R>, args: Array<unknown>): Effect.Effect<unknow
return visit(record(runner.prototypes.Object, { "": parsed }), "")
}
const stringify = <R>(runner: Runner<R>, args: Array<unknown>): Effect.Effect<unknown, unknown, R> => {
const stringify = <R>(runner: Runner<R>, args: Array<unknown>, node: AstNode): Effect.Effect<unknown, unknown, R> => {
const space = args[2]
const indent = typeof space === "number" || typeof space === "string" ? space : undefined
const replacer = args[1]
@@ -61,17 +64,17 @@ const stringify = <R>(runner: Runner<R>, args: Array<unknown>): Effect.Effect<un
// Validate up front; the replacer walk below reads the original value.
toProgram(runner.prototypes, args[0], "JSON.stringify value")
const apply = applyCollectionCallback(runner, replacer, "JSON.stringify")
const apply = applyCollectionCallback(runner, replacer, "JSON.stringify", node)
const stack = new Set<object>()
const visit = (holder: ProgramObject, key: string): Effect.Effect<unknown, unknown, R> =>
Effect.gen(function* () {
const value = yield* apply([key, yield* toJSONValue(runner, get(holder, key), key)])
const value = yield* apply([key, yield* toJSONValue(runner, get(holder, key), key, node)])
if (value === undefined || typeofValue(value) === "function") return undefined
toProgram(runner.prototypes, value, "JSON.stringify replacer result")
if (typeof value === "number") return Number.isFinite(value) ? value : null
if (value === null || typeof value === "string" || typeof value === "boolean") return value
if (!(value instanceof ProgramObject)) return {}
if (stack.has(value)) throw typeError("Converting circular structure to JSON.")
if (stack.has(value)) throw new InterpreterRuntimeError("Converting circular structure to JSON.", node)
stack.add(value)
if (value instanceof ProgramArray) {
const result: Array<unknown> = []
@@ -96,8 +99,8 @@ const stringify = <R>(runner: Runner<R>, args: Array<unknown>): Effect.Effect<un
}
// SerializeJSONProperty step 2: a callable `toJSON` decides the value, as Date and URL define.
const toJSONValue = <R>(runner: Runner<R>, value: unknown, key: string) => {
const toJSONValue = <R>(runner: Runner<R>, value: unknown, key: string, node: AstNode) => {
if (!(value instanceof ProgramObject)) return Effect.succeed(value)
const toJSON = get(value, "toJSON")
return toJSON instanceof Callable ? runner.invokeCallable(toJSON, value, [key]) : Effect.succeed(value)
return toJSON instanceof Callable ? runner.invokeCallable(toJSON, value, [key], node) : Effect.succeed(value)
}
+15 -11
View File
@@ -1,6 +1,6 @@
import { Effect } from "effect"
import { constants, type Method, methods } from "../interpreter/native.js"
import { typeError } from "../interpreter/model.js"
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import { ProgramObject } from "../interpreter/objects.js"
import { preserveConsumerError, type Runner } from "../interpreter/runner.js"
@@ -13,28 +13,32 @@ declare global {
// Validate only the arguments a method consumes; like JS, extras are ignored
// (so built-ins work as callbacks receiving (element, index, array)).
const number = (name: string, args: Array<unknown>, index: number): number => {
const number = (name: string, args: Array<unknown>, index: number, node: AstNode): number => {
if (index >= args.length) return Number.NaN
const arg = args[index]
if (typeof arg !== "number") throw typeError(`Math.${name} expects number arguments.`)
if (typeof arg !== "number") throw new InterpreterRuntimeError(`Math.${name} expects number arguments.`, node)
return arg
}
const unary = (name: string, op: (a: number) => number): Method => [name, 1, (_, args) => op(number(name, args, 0))]
const unary = (name: string, op: (a: number) => number): Method => [
name,
1,
(_, args, node) => op(number(name, args, 0, node)),
]
const binary = (name: string, op: (a: number, b: number) => number): Method => [
name,
2,
(_, args) => op(number(name, args, 0), number(name, args, 1)),
(_, args, node) => op(number(name, args, 0, node), number(name, args, 1, node)),
]
const variadic = (name: string, op: (...values: Array<number>) => number): Method => [
name,
2,
(_, args) =>
(_, args, node) =>
op(
...args.map((arg) => {
if (typeof arg !== "number") throw typeError(`Math.${name} expects number arguments.`)
if (typeof arg !== "number") throw new InterpreterRuntimeError(`Math.${name} expects number arguments.`, node)
return arg
}),
),
@@ -93,11 +97,11 @@ export const mathGlobal = <R>(runner: Runner<R>) => {
[
"sumPrecise",
1,
(_, args) =>
(_, args, node) =>
Effect.gen(function* () {
const cursor = yield* runner.syncIterator(args[0])
const cursor = yield* runner.syncIterator(args[0], node)
if (cursor === undefined) {
throw typeError("Math.sumPrecise expects a synchronous iterable.")
throw new InterpreterRuntimeError("Math.sumPrecise expects a synchronous iterable.", node)
}
const numbers: Array<number> = []
while (true) {
@@ -107,7 +111,7 @@ export const mathGlobal = <R>(runner: Runner<R>) => {
cursor,
Effect.sync(() => {
if (typeof step.value !== "number") {
throw typeError("Math.sumPrecise expects an iterable of numbers.")
throw new InterpreterRuntimeError("Math.sumPrecise expects an iterable of numbers.", node)
}
numbers.push(step.value)
}),
+26 -21
View File
@@ -1,5 +1,5 @@
import { constructor, constants, methods } from "../interpreter/native.js"
import { rangeError, typeError } from "../interpreter/model.js"
import { type AstNode, InterpreterRuntimeError, rangeError } from "../interpreter/model.js"
import type { Runner } from "../interpreter/runner.js"
import { coercion, coerceToString } from "./value.js"
@@ -28,10 +28,10 @@ export const numberGlobal = <R>(runner: Runner<R>) => {
[
"parseInt",
2,
(_, args) => {
(_, args, node) => {
const radix = args[1]
if (radix !== undefined && typeof radix !== "number") {
throw typeError("Number.parseInt expects a numeric radix.")
throw new InterpreterRuntimeError("Number.parseInt expects a numeric radix.", node)
}
return parseInt(coerceToString(args[0]), radix)
},
@@ -39,44 +39,49 @@ export const numberGlobal = <R>(runner: Runner<R>) => {
["parseFloat", 1, (_, args) => parseFloat(coerceToString(args[0]))],
])
const self = (thisValue: unknown, name: string): number => {
const self = (thisValue: unknown, name: string, node: AstNode): number => {
if (typeof thisValue === "number") return thisValue
throw typeError(`Number.prototype.${name} requires that 'this' be a Number.`)
throw new InterpreterRuntimeError(`Number.prototype.${name} requires that 'this' be a Number.`, node)
}
const optNum = (name: string, arg: unknown): number | undefined => {
const optNum = (name: string, arg: unknown, node: AstNode): number | undefined => {
if (arg === undefined) return undefined
if (typeof arg !== "number") throw typeError(`Number.${name} expects a number argument.`)
if (typeof arg !== "number") throw new InterpreterRuntimeError(`Number.${name} expects a number argument.`, node)
return arg
}
methods(protos, protos.Number, [
["toFixed", 1, (thisValue, args) => self(thisValue, "toFixed").toFixed(optNum("toFixed", args[0]))],
[
"toFixed",
1,
(thisValue, args, node) => self(thisValue, "toFixed", node).toFixed(optNum("toFixed", args[0], node)),
],
[
"toExponential",
1,
(thisValue, args) => self(thisValue, "toExponential").toExponential(optNum("toExponential", args[0])),
(thisValue, args, node) =>
self(thisValue, "toExponential", node).toExponential(optNum("toExponential", args[0], node)),
],
[
"toPrecision",
1,
(thisValue, args) => {
const value = self(thisValue, "toPrecision")
const digits = optNum("toPrecision", args[0])
(thisValue, args, node) => {
const value = self(thisValue, "toPrecision", node)
const digits = optNum("toPrecision", args[0], node)
return digits === undefined ? value.toString() : value.toPrecision(digits)
},
],
[
"toString",
1,
(thisValue, args) => {
const value = self(thisValue, "toString")
const radix = optNum("toString", args[0])
(thisValue, args, node) => {
const value = self(thisValue, "toString", node)
const radix = optNum("toString", args[0], node)
if (radix !== undefined && (radix < 2 || radix > 36)) {
throw rangeError("Number.toString radix must be between 2 and 36.")
throw rangeError("Number.toString radix must be between 2 and 36.", node)
}
return value.toString(radix)
},
],
["valueOf", 0, (thisValue) => self(thisValue, "valueOf")],
["valueOf", 0, (thisValue, _, node) => self(thisValue, "valueOf", node)],
])
return number
}
@@ -88,13 +93,13 @@ export const booleanGlobal = <R>(runner: Runner<R>) => {
length: 1,
call: coercion(runner, "Boolean").call,
})
const self = (thisValue: unknown, name: string): boolean => {
const self = (thisValue: unknown, name: string, node: AstNode): boolean => {
if (typeof thisValue === "boolean") return thisValue
throw typeError(`Boolean.prototype.${name} requires that 'this' be a Boolean.`)
throw new InterpreterRuntimeError(`Boolean.prototype.${name} requires that 'this' be a Boolean.`, node)
}
methods(protos, protos.Boolean, [
["toString", 0, (thisValue) => String(self(thisValue, "toString"))],
["valueOf", 0, (thisValue) => self(thisValue, "valueOf")],
["toString", 0, (thisValue, _, node) => String(self(thisValue, "toString", node))],
["valueOf", 0, (thisValue, _, node) => self(thisValue, "valueOf", node)],
])
return boolean
}
+57 -43
View File
@@ -4,10 +4,9 @@ import { constructor, methods, receiver } from "../interpreter/native.js"
import {
type AstNode,
AsyncIteratorSymbol,
invalidData,
InterpreterRuntimeError,
IteratorSymbol,
rangeError,
typeError,
} from "../interpreter/model.js"
import {
Callable,
@@ -35,22 +34,22 @@ import { groupBy } from "./collections.js"
import { coerceToString } from "./value.js"
// ToObject for enumeration.
export const enumerableSource = <R>(
runner: Runner<R>,
label: string,
value: unknown,
node?: AstNode,
): ProgramObject => {
export const enumerableSource = <R>(runner: Runner<R>, label: string, value: unknown, node: AstNode): ProgramObject => {
if (value === null || value === undefined) {
throw typeError(`${label} cannot convert ${describeValue(value)} to an object.`, node)
throw new InterpreterRuntimeError(`${label} cannot convert ${describeValue(value)} to an object.`, node)
}
if (value instanceof ProgramPromise) {
throw invalidData(`${label} received an un-awaited Promise; await it before inspecting the result.`, node)
throw new InterpreterRuntimeError(
`${label} received an un-awaited Promise; await it before inspecting the result.`,
node,
"InvalidDataValue",
)
}
if (value instanceof ToolReference) {
throw invalidData(
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 (typeof value === "string") return new ProgramArray(runner.prototypes.Array, [...value])
@@ -58,33 +57,40 @@ export const enumerableSource = <R>(
return new ProgramObject(runner.prototypes.Object)
}
export const objectAssign = <R>(runner: Runner<R>, args: Array<unknown>): unknown => {
export const objectAssign = <R>(runner: Runner<R>, 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 instanceof ProgramObject)) {
throw typeError(`Object.assign expects a data object or array target, received ${describeValue(target)}.`)
throw new InterpreterRuntimeError(
`Object.assign expects a data object or array target, received ${describeValue(target)}.`,
node,
)
}
const seen = new Set<object>()
for (const source of args.slice(1)) {
if (source === null || source === undefined) continue
const from = enumerableSource(runner, "Object.assign(...)", source)
const from = enumerableSource(runner, "Object.assign(...)", source, node)
for (const key of enumerableKeys(from)) {
rejectCircularInsertion(target, getOwn(from, key), "Object.assign result", seen)
rejectCircularInsertion(target, getOwn(from, key), "Object.assign result", node, seen)
if (!set(target, key, getOwn(from, key))) {
if (target instanceof ProgramArray && key === "length") throw rangeError("Invalid array length")
throw typeError(`Cannot assign to read only property '${String(key)}'.`)
if (target instanceof ProgramArray && key === "length") throw rangeError("Invalid array length", node)
throw new InterpreterRuntimeError(`Cannot assign to read only property '${String(key)}'.`, node)
}
}
}
return target
}
const objectFromEntries = <R>(runner: Runner<R>, source: unknown): Effect.Effect<ProgramObject, unknown, R> => {
const objectFromEntries = <R>(
runner: Runner<R>,
source: unknown,
node: AstNode,
): Effect.Effect<ProgramObject, unknown, R> => {
const out = new ProgramObject(runner.prototypes.Object)
return Effect.gen(function* () {
const cursor = yield* runner.syncIterator(source)
const cursor = yield* runner.syncIterator(source, node)
if (cursor === undefined) {
throw typeError("Object.fromEntries expects a synchronous iterable of entries.")
throw new InterpreterRuntimeError("Object.fromEntries expects a synchronous iterable of entries.", node)
}
while (true) {
const step = yield* cursor.next
@@ -93,7 +99,7 @@ const objectFromEntries = <R>(runner: Runner<R>, source: unknown): Effect.Effect
cursor,
Effect.sync(() => {
if (!(step.value instanceof ProgramObject) || containsOpaqueReference(step.value)) {
throw typeError("Object.fromEntries expects [key, value] entry objects.")
throw new InterpreterRuntimeError("Object.fromEntries expects [key, value] entry objects.", node)
}
define(out, coerceToString(getOwn(step.value, 0)), getOwn(step.value, 1))
}),
@@ -126,83 +132,91 @@ export const objectGlobal = <R>(
toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>,
) => {
const protos = runner.prototypes
const construct = (args: Array<unknown>): unknown => {
const construct = (args: Array<unknown>, node: AstNode): unknown => {
const first = args[0]
if (first === null || first === undefined) return new ProgramObject(protos.Object)
if (typeof first === "object") return first
throw typeError(`Object(${typeof first}) wrapper objects are not supported; use the primitive value directly.`)
throw new InterpreterRuntimeError(
`Object(${typeof first}) wrapper objects are not supported; use the primitive value directly.`,
node,
)
}
const object = constructor<R>(protos, protos.Object, {
name: "Object",
length: 1,
call: (_, args) => Effect.sync(() => construct(args)),
construct: (args) => Effect.sync(() => construct(args)),
call: (_, args, node) => Effect.sync(() => construct(args, node)),
construct: (args, _, node) => Effect.sync(() => construct(args, node)),
})
methods(protos, object, [
[
"keys",
1,
(_, args) =>
(_, args, node) =>
toProgram(
protos,
args[0] instanceof ToolReference
? [...toolKeys(args[0].path)]
: keys(enumerableSource(runner, "Object.keys(...)", args[0])),
: keys(enumerableSource(runner, "Object.keys(...)", args[0], node)),
"Object.keys result",
),
],
[
"values",
1,
(_, args) =>
(_, args, node) =>
new ProgramArray(
protos.Array,
entries(enumerableSource(runner, "Object.values(...)", args[0])).map((entry) => entry[1]),
entries(enumerableSource(runner, "Object.values(...)", args[0], node)).map((entry) => entry[1]),
),
],
[
"entries",
1,
(_, args) =>
(_, args, node) =>
new ProgramArray(
protos.Array,
entries(enumerableSource(runner, "Object.entries(...)", args[0])).map(
entries(enumerableSource(runner, "Object.entries(...)", args[0], node)).map(
(entry) => new ProgramArray(protos.Array, entry),
),
),
],
["hasOwn", 2, (_, args) => hasOwn(enumerableSource(runner, "Object.hasOwn(...)", args[0]), propertyKey(args[1]))],
[
"hasOwn",
2,
(_, args, node) => hasOwn(enumerableSource(runner, "Object.hasOwn(...)", args[0], node), propertyKey(args[1])),
],
[
"is",
2,
(_, args) => {
(_, args, node) => {
if (containsOpaqueReference(args[0]) || containsOpaqueReference(args[1])) {
throw invalidData("Object.is requires data values.")
throw new InterpreterRuntimeError("Object.is requires data values.", node, "InvalidDataValue")
}
return Object.is(args[0], args[1])
},
],
["assign", 2, (_, args) => objectAssign(runner, args)],
["fromEntries", 1, (_, args) => objectFromEntries(runner, args[0])],
["assign", 2, (_, args, node) => objectAssign(runner, args, node)],
["fromEntries", 1, (_, args, node) => objectFromEntries(runner, args[0], node)],
])
define(object, "groupBy", groupBy(runner, "Object"), hidden)
methods(protos, protos.Object, [
[
"hasOwnProperty",
1,
(thisValue, args) =>
hasOwn(receiver(ProgramObject, thisValue, "Object.prototype.hasOwnProperty"), propertyKey(args[0])),
(thisValue, args, node) =>
hasOwn(receiver(ProgramObject, thisValue, "Object.prototype.hasOwnProperty", node), propertyKey(args[0])),
],
[
"isPrototypeOf",
1,
(thisValue, args) => hasPrototype(args[0], receiver(ProgramObject, thisValue, "Object.prototype.isPrototypeOf")),
(thisValue, args, node) =>
hasPrototype(args[0], receiver(ProgramObject, thisValue, "Object.prototype.isPrototypeOf", node)),
],
[
"propertyIsEnumerable",
1,
(thisValue, args) =>
own(receiver(ProgramObject, thisValue, "Object.prototype.propertyIsEnumerable"), propertyKey(args[0]))
(thisValue, args, node) =>
own(receiver(ProgramObject, thisValue, "Object.prototype.propertyIsEnumerable", node), propertyKey(args[0]))
?.enumerable === true,
],
["toString", 0, (thisValue) => `[object ${classTag(thisValue)}]`],
@@ -210,9 +224,9 @@ export const objectGlobal = <R>(
[
"valueOf",
0,
(thisValue) => {
(thisValue, _, node) => {
if (thisValue === null || thisValue === undefined) {
throw typeError("Object.prototype.valueOf called on null or undefined.")
throw new InterpreterRuntimeError("Object.prototype.valueOf called on null or undefined.", node)
}
return thisValue
},
+18 -11
View File
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { Prototypes } from "../interpreter/intrinsics.js"
import { constructor, type Method, methods, prototypeFrom, receiver } from "../interpreter/native.js"
import { syntaxError, typeError } from "../interpreter/model.js"
import { type AstNode, InterpreterRuntimeError, syntaxError } from "../interpreter/model.js"
import {
define,
defineAccessor,
@@ -32,7 +32,7 @@ const regexFailureReason = (error: unknown): string =>
const escapeRegexHint =
'To match special characters like ( ) [ ] { } + * ? . literally, escape them with a backslash (e.g. "\\\\(") or test for them with String.includes instead.'
export const toHostRegex = (arg: unknown, method: string, extraFlags = ""): RegExp => {
export const toHostRegex = (arg: unknown, method: string, node: AstNode, extraFlags = ""): RegExp => {
// Native parity: an undefined pattern behaves as an empty pattern.
if (arg === undefined) return new RegExp("", extraFlags)
if (arg instanceof ProgramRegExp) return arg.regex
@@ -42,11 +42,13 @@ export const toHostRegex = (arg: unknown, method: string, extraFlags = ""): RegE
} catch (error) {
throw syntaxError(
`String.${method} received the string ${JSON.stringify(arg)}, which is not a valid regular expression pattern (${regexFailureReason(error)}). ${escapeRegexHint}`,
node,
)
}
}
throw typeError(
throw new InterpreterRuntimeError(
`String.${method} expects a regular expression (a /pattern/flags literal or new RegExp(...)) or a string pattern, not ${arg === null ? "null" : typeof arg}.`,
node,
)
}
@@ -65,6 +67,7 @@ export const matchToValue = (protos: Prototypes, match: RegExpMatchArray): Progr
export const constructRegExp = (
protos: Prototypes,
args: Array<unknown>,
node: AstNode,
proto: ProgramObject = protos.RegExp,
): ProgramRegExp => {
const first = args[0]
@@ -73,6 +76,7 @@ export const constructRegExp = (
if (flagsArg !== undefined && typeof flagsArg !== "string") {
throw syntaxError(
`RegExp flags must be a string of flag characters (e.g. "g", "gi"), not ${flagsArg === null ? "null" : typeof flagsArg}.`,
node,
)
}
const flags = flagsArg ?? (first instanceof ProgramRegExp ? first.regex.flags : "")
@@ -84,6 +88,7 @@ export const constructRegExp = (
/flag/i.test(reason)
? `new RegExp(...) received invalid flags ${JSON.stringify(flags)} (${reason}). Valid flags are d, g, i, m, s, u, v, and y.`
: `new RegExp(...) received ${JSON.stringify(pattern)}, which is not a valid regular expression pattern (${reason}). ${escapeRegexHint}`,
node,
)
}
}
@@ -101,21 +106,23 @@ export const regexpGlobal = <R>(runner: Runner<R>) => {
const regexp = constructor<R>(protos, proto, {
name: "RegExp",
length: 2,
call: (_, args) => Effect.sync(() => constructRegExp(protos, args)),
construct: (args, newTarget) => Effect.sync(() => constructRegExp(protos, args, prototypeFrom(newTarget, proto))),
call: (_, args, node) => Effect.sync(() => constructRegExp(protos, args, node)),
construct: (args, newTarget, node) =>
Effect.sync(() => constructRegExp(protos, args, node, prototypeFrom(newTarget, proto))),
})
methods(protos, regexp, [
[
"escape",
1,
(_, args) => {
if (typeof args[0] !== "string") throw typeError("RegExp.escape expects a string.")
(_, args, node) => {
if (typeof args[0] !== "string") throw new InterpreterRuntimeError("RegExp.escape expects a string.", node)
return RegExp.escape(args[0])
},
],
])
const self = (thisValue: unknown, name: string) => receiver(ProgramRegExp, thisValue, `RegExp.prototype.${name}`)
const self = (thisValue: unknown, name: string, node?: AstNode) =>
receiver(ProgramRegExp, thisValue, `RegExp.prototype.${name}`, node)
defineAccessor(proto, "source", (thisValue) => self(thisValue, "source").regex.source)
defineAccessor(proto, "flags", (thisValue) => self(thisValue, "flags").regex.flags)
for (const name of flagProperties) defineAccessor(proto, name, (thisValue) => self(thisValue, name).regex[name])
@@ -123,8 +130,8 @@ export const regexpGlobal = <R>(runner: Runner<R>) => {
const run = (name: "exec" | "test"): Method => [
name,
1,
(thisValue, args) => {
const value = self(thisValue, name)
(thisValue, args, node) => {
const value = self(thisValue, name, node)
const input = coerceToString(args[0])
const stateful = value.regex.global || value.regex.sticky
value.regex.lastIndex = toLength(getOwn(value, "lastIndex"))
@@ -137,7 +144,7 @@ export const regexpGlobal = <R>(runner: Runner<R>) => {
methods(protos, proto, [
run("exec"),
run("test"),
["toString", 0, (thisValue) => coerceToString(self(thisValue, "toString"))],
["toString", 0, (thisValue, _, node) => coerceToString(self(thisValue, "toString", node))],
])
return regexp
}
+97 -73
View File
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import { toProgram } from "../data.js"
import { constructor, type Method, methods } from "../interpreter/native.js"
import { invalidData, rangeError, typeError } from "../interpreter/model.js"
import { type AstNode, InterpreterRuntimeError, rangeError } from "../interpreter/model.js"
import { ProgramArray, ProgramPromise, ProgramRegExp, record } from "../interpreter/objects.js"
import { containsOpaqueReference, typeofValue } from "../interpreter/references.js"
import { applyCollectionCallback, isSupportedCallback, type Runner } from "../interpreter/runner.js"
@@ -9,17 +9,22 @@ import { matchToValue, toHostRegex } from "./regexp.js"
import { coerceToNumber, coerceToString, coercion } from "./value.js"
// console is intercepted by the interpreter before reaching here.
const requireDataArgument = (name: string, index: number, arg: unknown): unknown => {
const requireDataArgument = (name: string, index: number, arg: unknown, node: AstNode): unknown => {
if (containsOpaqueReference(arg)) {
throw invalidData(`String.${name} expects argument ${index + 1} to be a data value.`)
throw new InterpreterRuntimeError(
`String.${name} expects argument ${index + 1} to be a data value.`,
node,
"InvalidDataValue",
)
}
return arg
}
const replaceAllNeedsGlobal = (pattern: RegExp) => {
const replaceAllNeedsGlobal = (pattern: RegExp, node: AstNode) => {
if (!pattern.global) {
throw typeError(
throw new InterpreterRuntimeError(
`String.replaceAll requires a regular expression with the global (g) flag: write /${pattern.source}/${pattern.flags}g, or use String.replace to replace only the first match.`,
node,
)
}
}
@@ -29,9 +34,10 @@ const replaceWithCallback = <R>(
value: string,
name: "replace" | "replaceAll",
args: Array<unknown>,
node: AstNode,
): Effect.Effect<unknown, unknown, R> => {
const protos = runner.prototypes
const apply = applyCollectionCallback(runner, args[1], `String.${name}`)
const apply = applyCollectionCallback(runner, args[1], `String.${name}`, node)
const matches: Array<{ readonly match: string; readonly offset: number; readonly args: Array<unknown> }> = []
const collect = (...callbackArgs: Array<unknown>): string => {
const match = callbackArgs[0]
@@ -39,7 +45,7 @@ const replaceWithCallback = <R>(
const hasGroups = groups !== null && typeof groups === "object"
const offset = callbackArgs[callbackArgs.length - (hasGroups ? 3 : 2)]
if (typeof match !== "string" || typeof offset !== "number") {
throw typeError(`String.${name} produced an invalid replacement match.`)
throw new InterpreterRuntimeError(`String.${name} produced an invalid replacement match.`, node)
}
if (hasGroups) callbackArgs[callbackArgs.length - 1] = record(protos.Object, groups as Record<string, unknown>)
matches.push({ match, offset, args: callbackArgs })
@@ -48,11 +54,11 @@ const replaceWithCallback = <R>(
const pattern = args[0]
if (pattern instanceof ProgramRegExp) {
if (name === "replaceAll") replaceAllNeedsGlobal(pattern.regex)
if (name === "replaceAll") replaceAllNeedsGlobal(pattern.regex, node)
if (name === "replace") value.replace(pattern.regex, collect)
else value.replaceAll(pattern.regex, collect)
} else {
const search = coerceToString(requireDataArgument(name, 0, pattern))
const search = coerceToString(requireDataArgument(name, 0, pattern, node))
if (name === "replace") value.replace(search, collect)
else value.replaceAll(search, collect)
}
@@ -85,11 +91,11 @@ export const stringGlobal = <R>(runner: Runner<R>) => {
const codeUnits = (name: string, op: (...codes: Array<number>) => string): Method => [
name,
1,
(_, args) =>
(_, args, node) =>
op(
...args.map((arg) => {
if (typeof arg !== "number") {
throw typeError(`String.${name} expects number arguments.`)
throw new InterpreterRuntimeError(`String.${name} expects number arguments.`, node)
}
return arg
}),
@@ -100,50 +106,52 @@ export const stringGlobal = <R>(runner: Runner<R>) => {
codeUnits("fromCodePoint", String.fromCodePoint),
])
const self = (thisValue: unknown, name: string): string => {
const self = (thisValue: unknown, name: string, node: AstNode): string => {
if (typeof thisValue === "string") return thisValue
if (thisValue === null || thisValue === undefined) {
throw typeError(`String.prototype.${name} called on null or undefined.`)
throw new InterpreterRuntimeError(`String.prototype.${name} called on null or undefined.`, node)
}
return coerceToString(thisValue)
}
// Coerce arguments like native JS; opaque runtime references still reject.
const str = (name: string, args: Array<unknown>, index: number): string =>
coerceToString(requireDataArgument(name, index, args[index]))
const num = (name: string, args: Array<unknown>, index: number): number =>
coerceToNumber(requireDataArgument(name, index, args[index]))
const optNum = (name: string, args: Array<unknown>, index: number): number | undefined =>
args[index] === undefined ? undefined : num(name, args, index)
const optStr = (name: string, args: Array<unknown>, index: number): string | undefined =>
args[index] === undefined ? undefined : str(name, args, index)
const rejectRegex = (name: string, args: Array<unknown>): void => {
const str = (name: string, args: Array<unknown>, index: number, node: AstNode): string =>
coerceToString(requireDataArgument(name, index, args[index], node))
const num = (name: string, args: Array<unknown>, index: number, node: AstNode): number =>
coerceToNumber(requireDataArgument(name, index, args[index], node))
const optNum = (name: string, args: Array<unknown>, index: number, node: AstNode): number | undefined =>
args[index] === undefined ? undefined : num(name, args, index, node)
const optStr = (name: string, args: Array<unknown>, index: number, node: AstNode): string | undefined =>
args[index] === undefined ? undefined : str(name, args, index, node)
const rejectRegex = (name: string, args: Array<unknown>, node: AstNode): void => {
if (args[0] instanceof ProgramRegExp) {
throw typeError(
throw new InterpreterRuntimeError(
`String.${name} cannot take a regular expression; use regex.test(string) or String.search instead.`,
node,
)
}
}
const simple = (name: string, length: number, op: (value: string, args: Array<unknown>) => unknown): Method => [
name,
length,
(thisValue, args) => op(self(thisValue, name), args),
]
const simple = (
name: string,
length: number,
op: (value: string, args: Array<unknown>, node: AstNode) => unknown,
): Method => [name, length, (thisValue, args, node) => op(self(thisValue, name, node), args, node)]
const replace = (name: "replace" | "replaceAll") =>
simple(name, 2, (value, args) => {
if (isSupportedCallback(args[1])) return replaceWithCallback(runner, value, name, args)
simple(name, 2, (value, args, node) => {
if (isSupportedCallback(args[1])) return replaceWithCallback(runner, value, name, args, node)
if (typeofValue(args[1]) === "function") {
throw typeError(
throw new InterpreterRuntimeError(
`String.${name} cannot use this callable as a replacer; wrap it in an arrow function, e.g. (match) => tools.ns.tool(match).`,
node,
)
}
if (args[0] instanceof ProgramRegExp) {
const pattern = args[0].regex
const replacement = str(name, args, 1)
if (name === "replaceAll") replaceAllNeedsGlobal(pattern)
const replacement = str(name, args, 1, node)
if (name === "replaceAll") replaceAllNeedsGlobal(pattern, node)
return name === "replace" ? value.replace(pattern, replacement) : value.replaceAll(pattern, replacement)
}
if (name === "replace") return value.replace(str(name, args, 0), str(name, args, 1))
return value.replaceAll(str(name, args, 0), str(name, args, 1))
if (name === "replace") return value.replace(str(name, args, 0, node), str(name, args, 1, node))
return value.replaceAll(str(name, args, 0, node), str(name, args, 1, node))
})
methods(protos, protos.String, [
@@ -157,60 +165,68 @@ export const stringGlobal = <R>(runner: Runner<R>) => {
simple("trimEnd", 0, (value) => value.trimEnd()),
simple("trimRight", 0, (value) => value.trimEnd()),
// Locale/options are deliberately unsupported; comparison uses the host default locale.
simple("localeCompare", 1, (value, args) => value.localeCompare(str("localeCompare", args, 0))),
simple("normalize", 0, (value, args) => {
const form = optStr("normalize", args, 0)
simple("localeCompare", 1, (value, args, node) => value.localeCompare(str("localeCompare", args, 0, node))),
simple("normalize", 0, (value, args, node) => {
const form = optStr("normalize", args, 0, node)
try {
return value.normalize(form)
} catch {
throw rangeError(
`String.normalize expects the form "NFC", "NFD", "NFKC", or "NFKD" (got ${JSON.stringify(form)}).`,
node,
)
}
}),
simple("split", 2, (value, args) => {
simple("split", 2, (value, args, node) => {
const wrap = (parts: Array<string>) => new ProgramArray(protos.Array, parts)
// Native: an undefined separator returns the whole string, not a split on "undefined",
// unless the limit truncates to zero.
const requestedLimit = optNum("split", args, 1)
const requestedLimit = optNum("split", args, 1, node)
if (args[0] === undefined) {
return wrap(requestedLimit !== undefined && requestedLimit >>> 0 === 0 ? [] : [value])
}
if (args[0] instanceof ProgramRegExp) return wrap(value.split(args[0].regex, requestedLimit))
return wrap(value.split(str("split", args, 0), requestedLimit === undefined ? undefined : requestedLimit >>> 0))
return wrap(
value.split(str("split", args, 0, node), requestedLimit === undefined ? undefined : requestedLimit >>> 0),
)
}),
simple("slice", 2, (value, args) => value.slice(optNum("slice", args, 0), optNum("slice", args, 1))),
simple("includes", 1, (value, args) => {
rejectRegex("includes", args)
return value.includes(str("includes", args, 0), optNum("includes", args, 1))
simple("slice", 2, (value, args, node) =>
value.slice(optNum("slice", args, 0, node), optNum("slice", args, 1, node)),
),
simple("includes", 1, (value, args, node) => {
rejectRegex("includes", args, node)
return value.includes(str("includes", args, 0, node), optNum("includes", args, 1, node))
}),
simple("startsWith", 1, (value, args) => {
rejectRegex("startsWith", args)
return value.startsWith(str("startsWith", args, 0), optNum("startsWith", args, 1))
simple("startsWith", 1, (value, args, node) => {
rejectRegex("startsWith", args, node)
return value.startsWith(str("startsWith", args, 0, node), optNum("startsWith", args, 1, node))
}),
simple("endsWith", 1, (value, args) => {
rejectRegex("endsWith", args)
return value.endsWith(str("endsWith", args, 0), optNum("endsWith", args, 1))
simple("endsWith", 1, (value, args, node) => {
rejectRegex("endsWith", args, node)
return value.endsWith(str("endsWith", args, 0, node), optNum("endsWith", args, 1, node))
}),
simple("indexOf", 1, (value, args) => value.indexOf(str("indexOf", args, 0), optNum("indexOf", args, 1))),
simple("lastIndexOf", 1, (value, args) =>
value.lastIndexOf(str("lastIndexOf", args, 0), optNum("lastIndexOf", args, 1)),
simple("indexOf", 1, (value, args, node) =>
value.indexOf(str("indexOf", args, 0, node), optNum("indexOf", args, 1, node)),
),
simple("lastIndexOf", 1, (value, args, node) =>
value.lastIndexOf(str("lastIndexOf", args, 0, node), optNum("lastIndexOf", args, 1, node)),
),
replace("replace"),
replace("replaceAll"),
simple("match", 1, (value, args) => {
const pattern = toHostRegex(args[0], "match")
simple("match", 1, (value, args, node) => {
const pattern = toHostRegex(args[0], "match", node)
const matched = value.match(pattern)
if (matched === null) return null
// Preserve the own `index` and `groups` properties on non-global matches.
if (pattern.global) return toProgram(protos, matched, "String.match result")
return matchToValue(protos, matched)
}),
simple("matchAll", 1, (value, args) => {
const pattern = toHostRegex(args[0], "matchAll", "g")
simple("matchAll", 1, (value, args, node) => {
const pattern = toHostRegex(args[0], "matchAll", node, "g")
if (!pattern.global) {
throw typeError(
throw new InterpreterRuntimeError(
`String.matchAll requires a regular expression with the global (g) flag: write /${pattern.source}/${pattern.flags}g, or use String.match for a single match.`,
node,
)
}
return new ProgramArray(
@@ -218,27 +234,35 @@ export const stringGlobal = <R>(runner: Runner<R>) => {
Array.from(value.matchAll(pattern), (match) => matchToValue(protos, match)),
)
}),
simple("search", 1, (value, args) => value.search(toHostRegex(args[0], "search"))),
simple("repeat", 1, (value, args) => {
const count = num("repeat", args, 0)
simple("search", 1, (value, args, node) => value.search(toHostRegex(args[0], "search", node))),
simple("repeat", 1, (value, args, node) => {
const count = num("repeat", args, 0, node)
if (!Number.isFinite(count) || count < 0) {
throw rangeError("String.repeat expects a finite non-negative count.")
throw rangeError("String.repeat expects a finite non-negative count.", node)
}
return value.repeat(count)
}),
simple("padStart", 1, (value, args) => value.padStart(num("padStart", args, 0), optStr("padStart", args, 1))),
simple("padEnd", 1, (value, args) => value.padEnd(num("padEnd", args, 0), optStr("padEnd", args, 1))),
simple("charAt", 1, (value, args) => value.charAt(optNum("charAt", args, 0) ?? 0)),
simple("at", 1, (value, args) => value.at(optNum("at", args, 0) ?? 0)),
simple("substring", 2, (value, args) =>
value.substring(optNum("substring", args, 0) ?? 0, optNum("substring", args, 1)),
simple("padStart", 1, (value, args, node) =>
value.padStart(num("padStart", args, 0, node), optStr("padStart", args, 1, node)),
),
simple("padEnd", 1, (value, args, node) =>
value.padEnd(num("padEnd", args, 0, node), optStr("padEnd", args, 1, node)),
),
simple("charAt", 1, (value, args, node) => value.charAt(optNum("charAt", args, 0, node) ?? 0)),
simple("at", 1, (value, args, node) => value.at(optNum("at", args, 0, node) ?? 0)),
simple("substring", 2, (value, args, node) =>
value.substring(optNum("substring", args, 0, node) ?? 0, optNum("substring", args, 1, node)),
),
simple("substr", 2, (value, args, node) =>
value.substr(optNum("substr", args, 0, node) ?? 0, optNum("substr", args, 1, node)),
),
simple("substr", 2, (value, args) => value.substr(optNum("substr", args, 0) ?? 0, optNum("substr", args, 1))),
simple("isWellFormed", 0, (value) => value.isWellFormed()),
simple("toWellFormed", 0, (value) => value.toWellFormed()),
simple("charCodeAt", 1, (value, args) => value.charCodeAt(optNum("charCodeAt", args, 0) ?? 0)),
simple("codePointAt", 1, (value, args) => value.codePointAt(optNum("codePointAt", args, 0) ?? 0)),
simple("concat", 1, (value, args) => value.concat(...args.map((_, index) => str("concat", args, index)))),
simple("charCodeAt", 1, (value, args, node) => value.charCodeAt(optNum("charCodeAt", args, 0, node) ?? 0)),
simple("codePointAt", 1, (value, args, node) => value.codePointAt(optNum("codePointAt", args, 0, node) ?? 0)),
simple("concat", 1, (value, args, node) =>
value.concat(...args.map((_, index) => str("concat", args, index, node))),
),
])
return string
}
+72 -56
View File
@@ -2,7 +2,7 @@ import { Effect } from "effect"
import { toProgram, ToolRuntimeError } from "../data.js"
import type { Prototypes } from "../interpreter/intrinsics.js"
import { constructor, fn, type Method, methods, prototypeFrom, receiver, requiresNew } from "../interpreter/native.js"
import { PendingThrow, typeError, uriError } from "../interpreter/model.js"
import { type AstNode, InterpreterRuntimeError, uriError } from "../interpreter/model.js"
import {
defineAccessor,
entries,
@@ -43,12 +43,15 @@ const uriFunctions: Record<UriFunction, (value: string) => string> = {
}
export const uriGlobal = <R>(runner: Runner<R>, name: UriFunction) =>
fn<R>(runner.prototypes, name, 1, (_, args) => {
fn<R>(runner.prototypes, name, 1, (_, args, node) => {
const value = uriArgument(runner.prototypes, args[0], `${name} input`)
try {
return uriFunctions[name](value)
} catch (error) {
throw uriError(`${name} received malformed URI data: ${error instanceof Error ? error.message : String(error)}`)
throw uriError(
`${name} received malformed URI data: ${error instanceof Error ? error.message : String(error)}`,
node,
)
}
})
@@ -58,29 +61,32 @@ const urlArgument = (protos: Prototypes, value: unknown, label: string): string
export const urlGlobal = <R>(runner: Runner<R>) => {
const protos = runner.prototypes
const proto = protos.URL
const construct = (args: Array<unknown>, into: ProgramObject): ProgramURL => {
const construct = (args: Array<unknown>, into: ProgramObject, node: AstNode): ProgramURL => {
if (args.length === 0) {
throw typeError("new URL(...) requires a URL string and an optional base URL.")
throw new InterpreterRuntimeError("new URL(...) requires a URL string and an optional base URL.", node)
}
const input = urlArgument(protos, args[0], "new URL input")
const base = args[1] === undefined ? undefined : urlArgument(protos, args[1], "new URL base")
try {
return new ProgramURL(into, protos.URLSearchParams, new URL(input, base))
} catch {
throw typeError(`new URL(...) received an invalid URL${base === undefined ? "" : " or base URL"}.`)
throw new InterpreterRuntimeError(
`new URL(...) received an invalid URL${base === undefined ? "" : " or base URL"}.`,
node,
)
}
}
const url = constructor<R>(protos, proto, {
name: "URL",
length: 1,
call: requiresNew("URL"),
construct: (args, newTarget) => Effect.sync(() => construct(args, prototypeFrom(newTarget, proto))),
construct: (args, newTarget, node) => Effect.sync(() => construct(args, prototypeFrom(newTarget, proto), node)),
})
const parse = (name: "canParse" | "parse"): Method => [
name,
1,
(_, args) => {
if (args.length === 0) throw typeError(`URL.${name} requires a URL argument.`)
(_, args, node) => {
if (args.length === 0) throw new InterpreterRuntimeError(`URL.${name} requires a URL argument.`, node)
const input = urlArgument(protos, args[0], `URL.${name} input`)
const base = args[1] === undefined ? undefined : urlArgument(protos, args[1], `URL.${name} base`)
try {
@@ -93,7 +99,8 @@ export const urlGlobal = <R>(runner: Runner<R>) => {
]
methods(protos, url, [parse("canParse"), parse("parse")])
const self = (thisValue: unknown, name: string) => receiver(ProgramURL, thisValue, `URL.prototype.${name}`)
const self = (thisValue: unknown, name: string, node?: AstNode) =>
receiver(ProgramURL, thisValue, `URL.prototype.${name}`, node)
for (const name of urlProperties) {
defineAccessor(
proto,
@@ -106,25 +113,25 @@ export const urlGlobal = <R>(runner: Runner<R>) => {
try {
;(target.url as unknown as Record<string, string>)[name] = uriArgument(protos, value, `URL.${name} value`)
} catch (error) {
if (error instanceof PendingThrow || error instanceof ToolRuntimeError) throw error
throw typeError(`URL.${name} received an invalid value.`)
if (error instanceof InterpreterRuntimeError || error instanceof ToolRuntimeError) throw error
throw new InterpreterRuntimeError(`URL.${name} received an invalid value.`)
}
},
)
}
defineAccessor(proto, "searchParams", (thisValue) => self(thisValue, "searchParams").searchParams)
methods(protos, proto, [
["toString", 0, (thisValue) => self(thisValue, "toString").url.href],
["toJSON", 0, (thisValue) => self(thisValue, "toJSON").url.href],
["toString", 0, (thisValue, _, node) => self(thisValue, "toString", node).url.href],
["toJSON", 0, (thisValue, _, node) => self(thisValue, "toJSON", node).url.href],
])
return url
}
const readPair = <R>(runner: Runner<R>, value: unknown): Effect.Effect<Array<string>, unknown, R> =>
const readPair = <R>(runner: Runner<R>, value: unknown, node: AstNode): Effect.Effect<Array<string>, unknown, R> =>
Effect.gen(function* () {
const cursor = yield* runner.syncIterator(value)
const cursor = yield* runner.syncIterator(value, node)
if (cursor === undefined) {
throw typeError("new URLSearchParams(...) expects iterable [name, value] pairs.")
throw new InterpreterRuntimeError("new URLSearchParams(...) expects iterable [name, value] pairs.", node)
}
const items: Array<string> = []
while (true) {
@@ -143,6 +150,7 @@ const constructURLSearchParams = <R>(
runner: Runner<R>,
init: unknown,
proto: ProgramObject,
node: AstNode,
): Effect.Effect<ProgramURLSearchParams, unknown, R> => {
const wrap = (params: URLSearchParams) => new ProgramURLSearchParams(proto, params)
if (init === undefined) return Effect.succeed(wrap(new URLSearchParams()))
@@ -152,27 +160,31 @@ const constructURLSearchParams = <R>(
return Effect.succeed(wrap(new URLSearchParams(coerceToString(init))))
}
return Effect.gen(function* () {
const cursor = yield* runner.syncIterator(init)
const cursor = yield* runner.syncIterator(init, node)
if (cursor !== undefined) {
const pairs: Array<Array<string>> = []
while (true) {
const step = yield* cursor.next
if (step.done) {
if (pairs.some((entry) => entry.length !== 2)) {
throw typeError("new URLSearchParams(...) expects iterable [name, value] pairs.")
throw new InterpreterRuntimeError("new URLSearchParams(...) expects iterable [name, value] pairs.", node)
}
return wrap(new URLSearchParams(pairs.map((entry): [string, string] => [entry[0] ?? "", entry[1] ?? ""])))
}
pairs.push(yield* preserveConsumerError(cursor, readPair(runner, step.value)))
pairs.push(yield* preserveConsumerError(cursor, readPair(runner, step.value, node)))
}
}
if (isRuntimeReference(init)) {
throw typeError("new URLSearchParams(...) expects a query string, data object, or synchronous iterable pairs.")
throw new InterpreterRuntimeError(
"new URLSearchParams(...) expects a query string, data object, or synchronous iterable pairs.",
node,
)
}
if (isWrapper(init)) return wrap(new URLSearchParams())
if (!(init instanceof ProgramObject)) {
throw typeError(
throw new InterpreterRuntimeError(
"new URLSearchParams(...) expects a query string, data object, iterable pairs, or URLSearchParams.",
node,
)
}
return wrap(
@@ -187,16 +199,20 @@ export const urlSearchParamsGlobal = <R>(runner: Runner<R>) => {
const searchParams = constructor<R>(protos, proto, {
name: "URLSearchParams",
call: requiresNew("URLSearchParams"),
construct: (args, newTarget) => constructURLSearchParams(runner, args[0], prototypeFrom(newTarget, proto)),
construct: (args, newTarget, node) =>
constructURLSearchParams(runner, args[0], prototypeFrom(newTarget, proto), node),
})
const self = (thisValue: unknown, name: string) =>
receiver(ProgramURLSearchParams, thisValue, `URLSearchParams.prototype.${name}`)
const self = (thisValue: unknown, name: string, node?: AstNode) =>
receiver(ProgramURLSearchParams, thisValue, `URLSearchParams.prototype.${name}`, node)
const wrap = (items: Array<unknown>) => new ProgramArray(protos.Array, items)
const arg = (name: string, args: Array<unknown>, index: number): string =>
uriArgument(protos, args[index], `URLSearchParams.${name} argument ${index + 1}`)
const requireArgs = (name: string, args: Array<unknown>, count: number): void => {
const requireArgs = (name: string, args: Array<unknown>, count: number, node: AstNode): void => {
if (args.length < count) {
throw typeError(`URLSearchParams.${name} requires ${count} argument${count === 1 ? "" : "s"}.`)
throw new InterpreterRuntimeError(
`URLSearchParams.${name} requires ${count} argument${count === 1 ? "" : "s"}.`,
node,
)
}
}
defineAccessor(proto, "size", (thisValue) => self(thisValue, "size").params.size)
@@ -204,18 +220,18 @@ export const urlSearchParamsGlobal = <R>(runner: Runner<R>) => {
[
"append",
2,
(thisValue, args) => {
requireArgs("append", args, 2)
self(thisValue, "append").params.append(arg("append", args, 0), arg("append", args, 1))
(thisValue, args, node) => {
requireArgs("append", args, 2, node)
self(thisValue, "append", node).params.append(arg("append", args, 0), arg("append", args, 1))
return undefined
},
],
[
"delete",
1,
(thisValue, args) => {
requireArgs("delete", args, 1)
const params = self(thisValue, "delete").params
(thisValue, args, node) => {
requireArgs("delete", args, 1, node)
const params = self(thisValue, "delete", node).params
if (args[1] !== undefined) params.delete(arg("delete", args, 0), arg("delete", args, 1))
else params.delete(arg("delete", args, 0))
return undefined
@@ -224,25 +240,25 @@ export const urlSearchParamsGlobal = <R>(runner: Runner<R>) => {
[
"get",
1,
(thisValue, args) => {
requireArgs("get", args, 1)
return self(thisValue, "get").params.get(arg("get", args, 0))
(thisValue, args, node) => {
requireArgs("get", args, 1, node)
return self(thisValue, "get", node).params.get(arg("get", args, 0))
},
],
[
"getAll",
1,
(thisValue, args) => {
requireArgs("getAll", args, 1)
return wrap(self(thisValue, "getAll").params.getAll(arg("getAll", args, 0)))
(thisValue, args, node) => {
requireArgs("getAll", args, 1, node)
return wrap(self(thisValue, "getAll", node).params.getAll(arg("getAll", args, 0)))
},
],
[
"has",
1,
(thisValue, args) => {
requireArgs("has", args, 1)
const params = self(thisValue, "has").params
(thisValue, args, node) => {
requireArgs("has", args, 1, node)
const params = self(thisValue, "has", node).params
return args[1] !== undefined
? params.has(arg("has", args, 0), arg("has", args, 1))
: params.has(arg("has", args, 0))
@@ -251,36 +267,36 @@ export const urlSearchParamsGlobal = <R>(runner: Runner<R>) => {
[
"set",
2,
(thisValue, args) => {
requireArgs("set", args, 2)
self(thisValue, "set").params.set(arg("set", args, 0), arg("set", args, 1))
(thisValue, args, node) => {
requireArgs("set", args, 2, node)
self(thisValue, "set", node).params.set(arg("set", args, 0), arg("set", args, 1))
return undefined
},
],
[
"sort",
0,
(thisValue) => {
self(thisValue, "sort").params.sort()
(thisValue, _, node) => {
self(thisValue, "sort", node).params.sort()
return undefined
},
],
["keys", 0, (thisValue) => wrap(Array.from(self(thisValue, "keys").params.keys()))],
["values", 0, (thisValue) => wrap(Array.from(self(thisValue, "values").params.values()))],
["keys", 0, (thisValue, _, node) => wrap(Array.from(self(thisValue, "keys", node).params.keys()))],
["values", 0, (thisValue, _, node) => wrap(Array.from(self(thisValue, "values", node).params.values()))],
[
"entries",
0,
(thisValue) =>
wrap(Array.from(self(thisValue, "entries").params.entries(), ([key, value]) => wrap([key, value]))),
(thisValue, _, node) =>
wrap(Array.from(self(thisValue, "entries", node).params.entries(), ([key, value]) => wrap([key, value]))),
],
["toString", 0, (thisValue) => self(thisValue, "toString").params.toString()],
["toString", 0, (thisValue, _, node) => self(thisValue, "toString", node).params.toString()],
[
"forEach",
1,
(thisValue, args) => {
requireArgs("forEach", args, 1)
const target = self(thisValue, "forEach")
const apply = applyCollectionCallback(runner, args[0], "URLSearchParams.forEach")
(thisValue, args, node) => {
requireArgs("forEach", args, 1, node)
const target = self(thisValue, "forEach", node)
const apply = applyCollectionCallback(runner, args[0], "URLSearchParams.forEach", node)
return Effect.gen(function* () {
for (const [key, value] of Array.from(target.params.entries())) yield* apply([value, key, target])
return undefined
+5 -5
View File
@@ -1,6 +1,6 @@
import { toProgram } from "../data.js"
import { fn } from "../interpreter/native.js"
import { typeError } from "../interpreter/model.js"
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import {
get,
isWrapper,
@@ -55,7 +55,7 @@ export const coerceToNumber = (value: unknown): number => {
export type Coercion = "Number" | "String" | "Boolean" | "parseInt" | "parseFloat" | "isFinite" | "isNaN"
const coerce = <R>(runner: Runner<R>, name: Coercion, args: Array<unknown>): unknown => {
const coerce = <R>(runner: Runner<R>, name: Coercion, args: Array<unknown>, node: AstNode): unknown => {
// Native: Number() is 0 and String() is "", unlike their undefined-argument forms; the
// other coercers match native through the undefined-argument path below.
if (args.length === 0) {
@@ -80,7 +80,7 @@ const coerce = <R>(runner: Runner<R>, name: Coercion, args: Array<unknown>): unk
if (name === "parseInt") {
const radix = args[1]
if (radix !== undefined && typeof radix !== "number") {
throw typeError("parseInt expects a numeric radix.")
throw new InterpreterRuntimeError("parseInt expects a numeric radix.", node)
}
return parseInt(coerceToString(value), radix)
}
@@ -90,6 +90,6 @@ const coerce = <R>(runner: Runner<R>, name: Coercion, args: Array<unknown>): unk
/** A global coercion function such as `Number` or `parseInt`. */
export const coercion = <R>(runner: Runner<R>, name: Coercion, length = 1): NativeFunction<R> =>
fn(runner.prototypes, name, length, (_, args) =>
toProgram(runner.prototypes, coerce(runner, name, args), `${name} result`),
fn(runner.prototypes, name, length, (_, args, node) =>
toProgram(runner.prototypes, coerce(runner, name, args, node), `${name} result`),
)
+4 -4
View File
@@ -1,5 +1,5 @@
import { fn, methods } from "../interpreter/native.js"
import { typeError } from "../interpreter/model.js"
import { InterpreterRuntimeError } from "../interpreter/model.js"
import { ProgramObject } from "../interpreter/objects.js"
import type { Runner } from "../interpreter/runner.js"
import { coerceToString } from "./value.js"
@@ -7,13 +7,13 @@ import { coerceToString } from "./value.js"
// WebIDL DOMString conversion: a missing argument is a TypeError, anything else stringifies. Invalid input is a
// TypeError as well; browsers throw a DOMException named InvalidCharacterError, which CodeMode does not have.
export const base64Global = <R>(runner: Runner<R>, name: "atob" | "btoa") =>
fn<R>(runner.prototypes, name, 1, (_, args) => {
if (args.length === 0) throw typeError(`${name} requires 1 argument (a string)`)
fn<R>(runner.prototypes, name, 1, (_, args, node) => {
if (args.length === 0) throw new InterpreterRuntimeError(`${name} requires 1 argument (a string)`, node)
const input = coerceToString(args[0])
try {
return name === "atob" ? atob(input) : btoa(input)
} catch {
throw typeError("The string contains invalid characters.")
throw new InterpreterRuntimeError("The string contains invalid characters.", node)
}
})
+1 -1
View File
@@ -347,7 +347,7 @@ describe("CodeMode console capture", () => {
)
expect(result.ok ? undefined : result.logs).toStrictEqual(["before failure"])
expect(result.ok ? undefined : result.error.message).toBe("Error: boom")
expect(result.ok ? undefined : result.error.message).toBe("Uncaught: boom")
})
test("prints NaN and Infinity literally instead of the JSON null", async () => {
-111
View File
@@ -1,111 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { CodeMode } from "../src/index.js"
// One failure is one program error object, and rethrowing it keeps the diagnostic it started with.
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("error identity", () => {
test("awaiting the same rejected promise twice yields the same error object", async () => {
expect(
await value(`
const fail = async () => { null.foo }
const p = fail()
const a = await p.catch((e) => e)
try { await p } catch (b) { return a === b }
`),
).toBe(true)
})
test("allSettled reasons for the same failure are identical", async () => {
expect(
await value(`
const fail = async () => { null.foo }
const p = fail()
const [a, b] = await Promise.allSettled([p, p])
return [a.reason === b.reason, a.reason.name, a.reason.message]
`),
).toEqual([true, "TypeError", "Cannot read properties of null (reading 'foo')."])
})
test("Promise.any collects the same object a direct catch would", async () => {
expect(
await value(`
const fail = async () => { null.foo }
const p = fail()
const direct = await p.catch((e) => e)
try { await Promise.any([p]) } catch (aggregate) { return aggregate.errors[0] === direct }
`),
).toBe(true)
})
})
describe("rethrown interpreter failures", () => {
test("keep their diagnostic kind and source location", async () => {
const failure = await error(`try { switch (Symbol) {} } catch (e) { throw e }`)
expect(failure.kind).toBe("InvalidDataValue")
expect(failure.message).toStartWith("Switch discriminants must be data values. (line ")
expect(failure.location).toBeDefined()
})
test("keep their location through a rejection handler", async () => {
const direct = await error(`
const fail = async () => { null.foo }
await fail()
`)
const rethrown = await error(`
const fail = async () => { null.foo }
await fail().catch((e) => { throw e })
`)
expect(direct.location).toBeDefined()
expect(rethrown).toEqual(direct)
})
})
describe("uncaught program throws", () => {
test("an Error reports as name: message", async () => {
const failure = await error(`throw new TypeError("bad input")`)
expect(failure).toEqual({ kind: "ExecutionFailure", message: "TypeError: bad input" })
})
test("a custom name is honored", async () => {
const failure = await error(`const e = new Error("x"); e.name = "ValidationError"; throw e`)
expect(failure.message).toBe("ValidationError: x")
})
test("non-Error values keep the Uncaught prefix", async () => {
expect((await error(`throw "boom"`)).message).toBe("Uncaught: boom")
expect((await error(`throw { code: 7 }`)).message).toBe('Uncaught: {"code":7}')
})
})
describe("host errors escaping built-ins", () => {
test("become the same-named program error", async () => {
expect(
await value(`
try { (1).toFixed(200) } catch (e) { return [e.name, e instanceof RangeError, e.message] }
`),
).toEqual(["RangeError", true, "toFixed() argument must be between 0 and 100"])
})
test("report the location of the call that raised them", async () => {
const failure = await error(`return [1].map((n) => n.toFixed(200))`)
expect(failure.kind).toBe("ExecutionFailure")
expect(failure.message).toBe("toFixed() argument must be between 0 and 100 (line 1, col 23)")
})
test("a failure inside a built-in called by another built-in is located at the outer call", async () => {
const failure = await error(`return Array.from({ [Symbol.iterator]: () => ({ next: 1 }) })`)
expect(failure.message).toBe("Iterator next must be a function. (line 1, col 8)")
})
})
+9 -9
View File
@@ -330,7 +330,7 @@ describe("first-class promise values", () => {
if (!result.ok) return
expect(result.value).toBe("done")
expect(result.warnings).toStrictEqual([
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Error: boom" },
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: boom" },
])
})
@@ -363,7 +363,7 @@ describe("first-class promise values", () => {
expect(result.truncated).toBe(true)
expect(typeof result.value).toBe("string")
expect(result.warnings).toStrictEqual([
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Error: boom" },
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: boom" },
])
})
@@ -399,9 +399,9 @@ describe("first-class promise values", () => {
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.warnings).toStrictEqual([
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Error: first" },
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: first" },
{ kind: "ToolFailure", message: "Unhandled rejection from an un-awaited promise: Lookup refused" },
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Error: third" },
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: third" },
])
})
@@ -417,8 +417,8 @@ describe("first-class promise values", () => {
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.warnings).toStrictEqual([
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Error: outer" },
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Error: inner" },
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: outer" },
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: inner" },
])
})
@@ -445,7 +445,7 @@ describe("first-class promise values", () => {
)
expect(result.ok).toBe(false)
if (result.ok) return
expect(result.error.message).toBe("Error: boom")
expect(result.error.message).toBe("Uncaught: boom")
expect("warnings" in result).toBe(false)
expect(trace.completed).toBe(0)
expect(trace.interrupted).toBe(1)
@@ -911,7 +911,7 @@ describe("Promise.resolve / Promise.reject", () => {
if (!result.ok) return
expect(result.value).toBe("done")
expect(result.warnings).toStrictEqual([
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Error: abandoned" },
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: abandoned" },
])
})
})
@@ -1068,7 +1068,7 @@ describe("promise chaining", () => {
expect(result.value).toBe("done")
// The source rejection belongs to the chain (no warning); only the derived tail warns.
expect(result.warnings).toStrictEqual([
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Error: boom" },
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: boom" },
])
})
+4 -4
View File
@@ -3,7 +3,7 @@
// test262's own assert.js relies on.
import path from "node:path"
import { Cause, Effect } from "effect"
import { materialize } from "../../src/interpreter/errors.js"
import { caughtErrorValue } from "../../src/interpreter/errors.js"
import { executeProgram } from "../../src/interpreter/execute.js"
import type { Host } from "../../src/interpreter/globals.js"
import { ProgramThrow } from "../../src/interpreter/model.js"
@@ -128,13 +128,13 @@ const harness = <R>(host: Host<R>, onDone: (error: unknown) => void): ReadonlyAr
[
"throws",
3,
(_, args) => {
(_, args, node) => {
const expected = args[0] instanceof Callable ? String(get(args[0], "name")) : show(args[0])
return host.runner.invokeCallable(args[1], undefined, []).pipe(
return host.runner.invokeCallable(args[1], undefined, [], node).pipe(
Effect.matchCauseEffect({
onFailure: (cause) => {
if (cause.reasons.some(Cause.isInterruptReason)) return Effect.failCause(cause)
const thrown = materialize(host.runner, Cause.squash(cause))
const thrown = caughtErrorValue(host.runner, Cause.squash(cause))
if (!(thrown instanceof ProgramObject)) return fail(`${prefix(args[2])}Thrown value was not an object!`)
const actual = get(thrown, "constructor")
if (actual === args[0]) return Effect.void
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-app",
"version": "2.0.3",
"version": "2.0.2",
"type": "module",
"license": "MIT",
"scripts": {
+2 -2
View File
@@ -9,8 +9,8 @@ export const config = {
github: {
repoUrl: "https://github.com/anomalyco/opencode",
starsFormatted: {
compact: "205K",
full: "205,000",
compact: "207K",
full: "207,000",
},
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/console-core",
"version": "2.0.3",
"version": "2.0.2",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-function",
"version": "2.0.3",
"version": "2.0.2",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-mail",
"version": "2.0.3",
"version": "2.0.2",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-support",
"version": "2.0.3",
"version": "2.0.2",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.3",
"version": "2.0.2",
"name": "@opencode/core",
"type": "module",
"license": "MIT",
+64 -75
View File
@@ -9,7 +9,6 @@ import { AppProcess } from "@opencode/util/process"
import { makeGlobalNode } from "@opencode/util/effect/app-node"
import { File } from "./file.js"
import { KeyedMutex } from "./effect/keyed-mutex.js"
import { VcsPatch } from "./vcs/patch.js"
export class Repository extends Schema.Class<Repository>("Git.Repository")({
worktree: AbsolutePath,
@@ -309,7 +308,7 @@ const layer = Layer.effect(
operationName: OperationError["operation"],
repository: Repository,
args: string[],
options?: { stdin?: string; env?: Record<string, string>; maxOutputBytes?: number },
options?: { stdin?: string; env?: Record<string, string> },
) {
const result = yield* proc
.run(
@@ -318,7 +317,7 @@ const layer = Layer.effect(
env: options?.env,
extendEnv: true,
}),
{ stdin: options?.stdin, maxOutputBytes: options?.maxOutputBytes },
{ stdin: options?.stdin },
)
.pipe(
Effect.mapError(
@@ -332,8 +331,7 @@ const layer = Layer.effect(
),
)
const text = result.stdout.toString("utf8")
if (result.exitCode === 0)
return { text, stderr: result.stderr.toString("utf8"), truncated: result.stdoutTruncated }
if (result.exitCode === 0) return { text, stderr: result.stderr.toString("utf8") }
return yield* new OperationError({
operation: operationName,
directory: repository.worktree,
@@ -387,7 +385,9 @@ const layer = Layer.effect(
maximumUntrackedFileBytes?: number
}) {
const list = (args: string[]) =>
repositoryOperation("refresh", input.repository, args).pipe(Effect.map((result) => nuls(result.text)))
repositoryOperation("refresh", input.repository, args).pipe(
Effect.map((result) => result.text.split("\0").filter(Boolean)),
)
const [tracked, untracked] = yield* Effect.all(
[
list(["diff-files", "--name-only", "-z", "--", input.scope]),
@@ -464,7 +464,13 @@ const layer = Layer.effect(
directory: input.repository.worktree,
message: result.stderr.toString("utf8").trim() || "Failed to check ignored paths",
})
return new Set(nuls(result.stdout.toString("utf8")).map((file) => RelativePath.make(file)))
return new Set(
result.stdout
.toString("utf8")
.split("\0")
.filter(Boolean)
.map((file) => RelativePath.make(file)),
)
})
const writeTree = Effect.fn("Git.tree.write")(function* (repository: Repository) {
@@ -493,23 +499,19 @@ const layer = Layer.effect(
to: TreeID
}) {
// Undo needs both paths of a rename, not only its destination.
return nuls(
(yield* repositoryOperation("list_files", input.repository, [
"diff",
"--name-only",
"--no-renames",
"-z",
input.from,
input.to,
])).text,
).map((file) => RelativePath.make(file))
return (yield* repositoryOperation("list_files", input.repository, [
"diff",
"--name-only",
"--no-renames",
"-z",
input.from,
input.to,
])).text
.split("\0")
.filter(Boolean)
.map((file) => RelativePath.make(file))
})
/**
* Three batched invocations over the tree pair instead of three per file. An
* explicit empty selection diffs nothing; an absent one diffs every changed path.
* Patch output is capped like VCS diffs: files past the cap get an empty patch.
*/
const treeDiff = Effect.fn("Git.tree.diff")(function* (input: {
repository: Repository
from: TreeID
@@ -517,57 +519,49 @@ const layer = Layer.effect(
context?: number
paths?: readonly RelativePath[]
}) {
if (input.paths?.length === 0) return []
const args = ["--no-renames", input.from, input.to, "--", ...(input.paths ?? [])]
// Patch headers have no -z form: unquoted paths keep chunksByFile matching non-ASCII names.
const [names, numbers, patch] = yield* Effect.all(
[
repositoryOperation("diff", input.repository, ["diff", "--name-status", "-z", ...args]),
repositoryOperation("diff", input.repository, ["diff", "--numstat", "-z", ...args]),
repositoryOperation(
const paths = input.paths ?? (yield* treeFiles(input))
return yield* Effect.forEach(paths, (file) =>
Effect.gen(function* () {
const statusText = (yield* repositoryOperation("diff", input.repository, [
"diff",
input.repository,
["-c", "core.quotepath=false", "diff", "--no-ext-diff", `--unified=${input.context ?? 3}`, ...args],
{ maxOutputBytes: VcsPatch.MAX_TOTAL_PATCH_BYTES },
),
],
{ concurrency: 3 },
)
const statuses = nuls(names.text)
const files = statuses.flatMap((code, index) => {
const file = statuses[index + 1]
if (index % 2 !== 0 || !file) return []
return [
{
file: RelativePath.make(file),
status: code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified",
} as const,
]
})
const stats = new Map(
nuls(numbers.text).flatMap((line) => {
const [additions, deletions, ...file] = line.split("\t")
if (!additions || !deletions || file.length === 0) return []
return [
[
file.join("\t"),
additions === "-" || deletions === "-"
? { binary: true, additions: 0, deletions: 0 }
: { binary: false, additions: Number(additions), deletions: Number(deletions) },
] as const,
]
"--name-status",
"--no-renames",
input.from,
input.to,
"--",
file,
])).text.trim()
const status = statusText.startsWith("A") ? "added" : statusText.startsWith("D") ? "deleted" : "modified"
const stats = (yield* repositoryOperation("diff", input.repository, [
"diff",
"--numstat",
"--no-renames",
input.from,
input.to,
"--",
file,
])).text.split("\t")
const binary = stats[0] === "-" || stats[1] === "-"
const patch = binary
? ""
: (yield* repositoryOperation("diff", input.repository, [
"diff",
`--unified=${input.context ?? 3}`,
"--no-renames",
input.from,
input.to,
"--",
file,
])).text
return {
file,
status,
additions: binary ? 0 : Number(stats[0] ?? 0),
deletions: binary ? 0 : Number(stats[1] ?? 0),
patch,
} satisfies File.Diff
}),
)
const patches = VcsPatch.chunksByFile(patch, (index) => files[index]?.file)
return files.map((entry) => {
const stat = stats.get(entry.file)
return {
...entry,
additions: stat?.additions ?? 0,
deletions: stat?.deletions ?? 0,
patch: stat?.binary ? "" : (patches.get(entry.file) ?? VcsPatch.emptyPatch(entry.file)),
} satisfies File.Diff
})
})
const hasEntry = Effect.fnUntraced(function* (repository: Repository, tree: TreeID, file: RelativePath) {
@@ -739,11 +733,6 @@ function execute(cwd: string, proc: AppProcess.Interface, args: string[]) {
)
}
/** Split NUL-terminated git output into its records. */
function nuls(text: string) {
return text.split("\0").filter(Boolean)
}
function resolvePath(cwd: string, value: string) {
const trimmed = value.replace(/[\r\n]+$/, "")
if (!trimmed) return cwd
-24
View File
@@ -55,11 +55,8 @@ import { SessionModelTransport } from "./session/model-transport.js"
import { llmClient } from "./effect/app-node-platform.js"
import { Snapshot } from "./snapshot.js"
import { Session } from "./session/session.js"
import { SessionDiff, TurnRangeError } from "./session/diff.js"
import { LocationServiceMap } from "./location-service-map.js"
import { FSUtil } from "@opencode/util/fs-util"
import type { EventLog } from "@opencode/schema/event-log"
import type { FileDiff } from "@opencode/schema/file-diff"
import { Job } from "./job.js"
import type { Command } from "./command.js"
import { SessionEnvironment } from "./session/environment.js"
@@ -112,7 +109,6 @@ export {
type InboxItemRef = { readonly sessionID: SessionSchema.ID; readonly inboxID: SessionMessage.ID }
export { DestinationNotFoundError, DestinationNotDirectoryError, DestinationUnavailableError }
export { TurnRangeError }
export interface Interface {
readonly list: (input?: ListInput) => Effect.Effect<{
@@ -139,13 +135,6 @@ export interface Interface {
readonly context: (
sessionID: SessionSchema.ID,
) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
/** Structured diffs of the files changed by a turn or range of turns; see `SessionDiff.turn`. */
readonly diff: (input: {
readonly sessionID: SessionSchema.ID
readonly from?: SessionMessage.ID
readonly to?: SessionMessage.ID
readonly context?: number
}) => Effect.Effect<readonly FileDiff.Info[], NotFoundError | MessageNotFoundError | TurnRangeError | Snapshot.Error>
/**
* Durable admitted session work not yet visible in projected history,
* ordered by admission. Includes unpromoted user and synthetic inputs and
@@ -238,7 +227,6 @@ const layer = Layer.effect(
const moves = yield* SessionMove.Service
const jobs = yield* Job.Service
const environments = yield* SessionEnvironment.Service
const locations = yield* LocationServiceMap.Service
const sessions = yield* Session.make()
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
@@ -371,17 +359,6 @@ const layer = Layer.effect(
yield* result.get(sessionID)
return yield* store.context(sessionID)
}),
diff: Effect.fn("Session.diff")(function* (input) {
const session = yield* result.get(input.sessionID)
const active = yield* execution.isActive(input.sessionID)
return yield* SessionDiff.turn(db, locations, {
session,
active,
from: input.from,
to: input.to,
context: input.context,
})
}),
inbox: (sessionID) => sessions.forSession(sessionID).inbox(),
cancelInbox: (input) => sessions.forSession(input.sessionID).cancelInbox(input.inboxID),
steerInbox: (input) => sessions.forSession(input.sessionID).steerInbox(input.inboxID),
@@ -471,7 +448,6 @@ export const node: LayerNode.Provider<Service, never, typeof Node.tags.values.gl
SessionInbox.node,
SessionMove.node,
SessionProjector.node,
LocationServiceMap.node,
FSUtil.node,
App.node,
],
-138
View File
@@ -1,138 +0,0 @@
export * as SessionDiff from "./diff.js"
import { and, asc, eq, gt, inArray, lt, or, sql } from "drizzle-orm"
import { Context, Effect, Schema } from "effect"
import { Location } from "@opencode/schema/location"
import { Database } from "../database/database.js"
import { LocationServiceMap } from "../location-service-map.js"
import { Snapshot } from "../snapshot.js"
import { PATCH_CONTEXT_LINES } from "../vcs/patch.js"
import { MessageNotFoundError } from "./error.js"
import { SessionMessage } from "./message.js"
import { SessionSchema } from "./schema.js"
import { SessionMessageTable } from "./sql.js"
export class TurnRangeError extends Schema.TaggedError<TurnRangeError>()("Session.TurnRangeError", {
sessionID: SessionSchema.ID,
field: Schema.Literals(["from", "to"]),
message: Schema.String,
}) {}
const decodeLocation = Schema.decodeUnknownSync(Schema.fromJsonString(Location.Ref))
/**
* Diff the files changed by the turn containing a user message. A turn runs from
* the first prompt after the Session was last idle until the next idle marker, so
* prompts steered in while it was busy belong to the same turn; `to` extends the
* range through the turn containing a later user message. Compares the range's
* first recorded start snapshot with its last recorded end snapshot; only a step
* still running in the active Session compares against the working copy. Like VCS
* diffs, an omitted `context` yields full-file patches.
*
* A Session without any idle marker predates them, so its prompts span until the
* next user message instead.
*
* Snapshot trees live in the repository of the Location that captured them, so a
* range spanning a location switch is rejected rather than diffed wrongly.
*/
export const turn = Effect.fn("SessionDiff.turn")(function* (
db: Database.Interface["db"],
locations: Context.Service.Shape<typeof LocationServiceMap.Service>,
input: {
readonly session: SessionSchema.Info
/** The process is currently executing this Session. */
readonly active: boolean
readonly from?: SessionMessage.ID
readonly to?: SessionMessage.ID
readonly context?: number
},
) {
const sessionID = input.session.id
const rows = yield* db
.select({ id: SessionMessageTable.id, type: SessionMessageTable.type, seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, sessionID),
or(
inArray(SessionMessageTable.type, ["user", "idle"]),
input.from ? eq(SessionMessageTable.id, input.from) : undefined,
input.to ? eq(SessionMessageTable.id, input.to) : undefined,
),
),
)
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
const users = rows.filter((row) => row.type === "user")
const markers = rows.filter((row) => row.type === "idle")
const resolve = Effect.fn(function* (field: "from" | "to", id: SessionMessage.ID) {
const row = rows.find((row) => row.id === id)
if (!row) return yield* new MessageNotFoundError({ sessionID, messageID: id })
if (row.type !== "user")
return yield* new TurnRangeError({ sessionID, field, message: `Message ${id} is not a user message` })
return row
})
const anchor = input.from ? yield* resolve("from", input.from) : users[users.length - 1]
if (!anchor) return []
const last = input.to ? yield* resolve("to", input.to) : anchor
if (last.seq < anchor.seq)
return yield* new TurnRangeError({ sessionID, field: "to", message: `Message ${last.id} precedes ${anchor.id}` })
// Without any marker, history predates idle markers and a prompt's turn ends at the next prompt.
const legacy = markers.length === 0
// The turn opens with the first prompt after the previous idle marker; the anchor itself is the latest candidate.
const opened = markers.findLast((row) => row.seq < anchor.seq)?.seq ?? -1
const start = legacy ? anchor.seq : (users.find((row) => row.seq > opened)?.seq ?? anchor.seq)
const end = legacy ? users.find((row) => row.seq > last.seq)?.seq : markers.find((row) => row.seq > last.seq)?.seq
const steps = yield* db
.select({
seq: SessionMessageTable.seq,
start: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.snapshot.start')`,
end: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.snapshot.end')`,
completed: sql<number | null>`json_extract(${SessionMessageTable.data}, '$.time.completed')`,
})
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, sessionID),
eq(SessionMessageTable.type, "assistant"),
gt(SessionMessageTable.seq, start),
end === undefined ? undefined : lt(SessionMessageTable.seq, end),
),
)
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
const first = steps[0]
const final = steps[steps.length - 1]
const from = steps.find((step) => step.start)?.start
if (!first || !final || !from) return []
const switches = yield* db
.select({
seq: SessionMessageTable.seq,
location: sql<string>`json_extract(${SessionMessageTable.data}, '$.location')`,
previous: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.previous.location')`,
})
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "location-switched")))
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
if (switches.some((row) => row.seq > first.seq && row.seq < final.seq))
return yield* new TurnRangeError({ sessionID, field: "to", message: "Turn range spans a location change" })
const before = switches.findLast((row) => row.seq < first.seq)?.location
const after = switches.find((row) => row.seq > first.seq)?.previous
const location = before ? decodeLocation(before) : after ? decodeLocation(after) : input.session.location
const recorded = steps.findLast((step) => step.end)?.end
return yield* Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
const running = input.active && final.completed === null
const to = running ? ((yield* snapshot.capture()) ?? recorded) : recorded
if (!to) return []
return yield* snapshot.diff({
from: Snapshot.ID.make(from),
to: Snapshot.ID.make(to),
context: input.context ?? PATCH_CONTEXT_LINES,
})
}).pipe(Effect.provide(locations.get(location)))
})
+3 -20
View File
@@ -60,21 +60,6 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
)
})
const idle = (outcome: SessionMessage.Idle["outcome"]) =>
clearCurrentRetry.pipe(
Effect.andThen(
adapter.appendMessage(
SessionMessage.Idle.make({
id: SessionMessage.ID.fromEvent(event.id),
type: "idle",
outcome,
metadata: event.metadata,
time: { created },
}),
),
),
)
const project = pipe(
Match.type<SessionEvent.DurableEvent>(),
Match.discriminatorsExhaustive("type")({
@@ -139,11 +124,9 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
"session.inbox.cancelled": () => Effect.void,
"session.inbox.delivery.changed": () => Effect.void,
"session.execution.started": () => Effect.void,
"session.execution.succeeded": () => idle("succeeded"),
"session.execution.failed": () => idle("failed"),
// Shutdown keeps the execution claim and the resumed drain continues the turn.
"session.execution.interrupted": (event) =>
event.data.reason === "shutdown" ? clearCurrentRetry : idle("interrupted"),
"session.execution.succeeded": () => clearCurrentRetry,
"session.execution.failed": () => clearCurrentRetry,
"session.execution.interrupted": () => clearCurrentRetry,
"session.instructions.updated": (event) => {
if (event.data.text === undefined) return Effect.void
return adapter.appendMessage(
@@ -226,7 +226,6 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
switch (message.type) {
case "agent-switched":
case "model-switched":
case "idle":
return []
case "location-switched":
return [
+16 -33
View File
@@ -131,55 +131,38 @@ const layer = Layer.effect(
)
})
const comparison = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
const compare = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
const repo = yield* repository.pipe(Effect.mapError((cause) => failure(operation, cause)))
return {
source: repo.source,
const comparison = {
repository: repo.snapshotRepository,
from: Git.TreeID.make(input.from),
to: Git.TreeID.make(input.to),
}
})
// Snapshots track every scoped file; the source repository's ignore rules decide what callers see.
const ignored = Effect.fnUntraced(function* (
operation: "files" | "diff",
source: Git.Repository,
paths: readonly RelativePath[],
) {
return yield* git.index
.ignored({ repository: source, paths })
const files = yield* git.tree.files(comparison).pipe(Effect.mapError((cause) => failure(operation, cause)))
const ignored = yield* git.index
.ignored({ repository: repo.source, paths: files })
.pipe(Effect.mapError((cause) => failure(operation, cause)))
return {
input: comparison,
files,
ignored,
}
})
const files = Effect.fn("Snapshot.files")(function* (input: CompareInput) {
const compared = yield* comparison("files", input)
const changed = yield* git.tree
.files({ repository: compared.repository, from: compared.from, to: compared.to })
.pipe(Effect.mapError((cause) => failure("files", cause)))
const skipped = yield* ignored("files", compared.source, changed)
return changed.filter((file) => !skipped.has(file))
const comparison = yield* compare("files", input)
return comparison.files.filter((file) => !comparison.ignored.has(file))
})
const diff = Effect.fn("Snapshot.diff")(function* (input: DiffInput) {
if (input.paths?.length === 0) return []
const compared = yield* comparison("diff", input)
// Only an explicit selection becomes a pathspec; ignored paths are dropped from the result instead.
const diffs = yield* git.tree
const comparison = yield* compare("diff", input)
return yield* git.tree
.diff({
repository: compared.repository,
from: compared.from,
to: compared.to,
...comparison.input,
context: input.context,
paths: input.paths,
paths: (input.paths ?? comparison.files).filter((file) => !comparison.ignored.has(file)),
})
.pipe(Effect.mapError((cause) => failure("diff", cause)))
const skipped = yield* ignored(
"diff",
compared.source,
diffs.map((file) => RelativePath.make(file.file)),
)
return diffs.filter((file) => !skipped.has(RelativePath.make(file.file)))
})
const plan = Effect.fnUntraced(function* (worktree: AbsolutePath, input: RestoreInput) {
+3 -6
View File
@@ -2,7 +2,6 @@ export * as OpenCodeTools from "./opencode.js"
import { SystemPart, ToolFailure } from "@opencode/ai"
import type { Context } from "@opencode/plugin/effect/plugin"
import type { SessionHooks } from "@opencode/plugin/effect/session"
import { AbsolutePath } from "@opencode/schema/schema"
import { Session } from "@opencode/schema/session"
import { Effect, Schema } from "effect"
@@ -26,17 +25,15 @@ const MoveOutput = Schema.Struct({ sessionID: Session.ID, directory: AbsolutePat
export const Plugin = {
id: "opencode.tools",
effect: Effect.fn("OpenCodeTools.Plugin")(function* (ctx: Context) {
const hook = (event: SessionHooks["context"]) =>
yield* ctx.session.hook("context", (event) =>
Effect.sync(() => {
event.system.push(
SystemPart.make(
"When you create a worktree outside the current working directory and intend to use it as your primary working directory, consider using `execute` to call `tools.opencode.session_move` and make the worktree the session's working directory.",
),
)
})
yield* ctx.session.hook("context", hook)
yield* ctx.session.hook("compaction", hook)
yield* ctx.session.hook("generate", hook)
}),
)
yield* ctx.tool
.transform((draft) => {
draft.namespace({ name: "opencode", description: "OpenCode session and runtime tools." })
+3 -6
View File
@@ -1,7 +1,6 @@
export * as PatchTool from "./patch.js"
import type { Context } from "@opencode/plugin/effect/plugin"
import type { SessionHooks } from "@opencode/plugin/effect/session"
import { ToolFailure } from "@opencode/ai"
import { FileDiff } from "@opencode/schema/file-diff"
import { Effect, Result, Schema } from "effect"
@@ -293,7 +292,7 @@ export const Plugin = {
)
.pipe(Effect.orDie)
const hook = (event: SessionHooks["context"]) =>
yield* ctx.session.hook("context", (event) =>
Effect.sync(() => {
const usePatch =
event.model.id.includes("gpt-") && !event.model.id.includes("oss") && !event.model.id.includes("gpt-4")
@@ -303,10 +302,8 @@ export const Plugin = {
return
}
delete event.tools.patch
})
yield* ctx.session.hook("context", hook)
yield* ctx.session.hook("compaction", hook)
yield* ctx.session.hook("generate", hook)
}),
)
}),
}
+3 -6
View File
@@ -2,7 +2,6 @@ export * as ShellTool from "./shell.js"
import { ToolFailure } from "@opencode/ai"
import type { Context } from "@opencode/plugin/effect/plugin"
import type { SessionHooks } from "@opencode/plugin/effect/session"
import type { ShellCreateBefore } from "@opencode/plugin/effect/shell"
import type { Tool } from "@opencode/schema/tool"
import { Deferred, Effect, Schema, Scope } from "effect"
@@ -272,14 +271,12 @@ export const Plugin = {
)
.pipe(Effect.orDie)
const hook = (event: SessionHooks["context"]) =>
yield* ctx.session.hook("context", (event) =>
Effect.gen(function* () {
const tool = event.tools[name]
if (!tool) return
tool.description = description(ShellSelect.name(yield* compatibleShell))
})
yield* ctx.session.hook("context", hook)
yield* ctx.session.hook("compaction", hook)
yield* ctx.session.hook("generate", hook)
}),
)
}),
}
+3 -6
View File
@@ -2,7 +2,6 @@ export * as SubagentTool from "./subagent.js"
import { ToolFailure } from "@opencode/ai"
import type { Context } from "@opencode/plugin/effect/plugin"
import type { SessionHooks } from "@opencode/plugin/effect/session"
import { Effect, Schema } from "effect"
import { Agent } from "../../agent.js"
import { Config } from "../../config.js"
@@ -235,7 +234,7 @@ export const Plugin = {
)
.pipe(Effect.orDie)
const hook = (event: SessionHooks["context"]) =>
yield* ctx.session.hook("context", (event) =>
Effect.gen(function* () {
const tool = event.tools[name]
if (!tool) return
@@ -259,9 +258,7 @@ export const Plugin = {
`- ${agent.id}: ${agent.description ?? "This subagent should only be called when explicitly requested."}`,
),
].join("\n")
})
yield* ctx.session.hook("context", hook)
yield* ctx.session.hook("compaction", hook)
yield* ctx.session.hook("generate", hook)
}),
)
}),
}
+3 -6
View File
@@ -1,7 +1,6 @@
export * as WebSearchTool from "./websearch.js"
import type { Context } from "@opencode/plugin/effect/plugin"
import type { SessionHooks } from "@opencode/plugin/effect/session"
import { ToolFailure } from "@opencode/ai"
import { Effect, Schema, Semaphore } from "effect"
import { HttpClientError } from "effect/unstable/http"
@@ -181,16 +180,14 @@ export const Plugin = {
)
.pipe(Effect.orDie)
const hook = (event: SessionHooks["context"]) =>
yield* ctx.session.hook("context", (event) =>
Effect.gen(function* () {
const disabled = yield* websearch.default().pipe(
Effect.as(false),
Effect.catchTag("WebSearch.Disabled", () => Effect.succeed(true)),
)
if (disabled) delete event.tools[name]
})
yield* ctx.session.hook("context", hook)
yield* ctx.session.hook("compaction", hook)
yield* ctx.session.hook("generate", hook)
}),
)
}),
}
-37
View File
@@ -6,7 +6,6 @@ import { Effect } from "effect"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Git } from "@opencode/core/git"
import { AbsolutePath, RelativePath } from "@opencode/core/schema"
import { VcsPatch } from "@opencode/core/vcs/patch"
import { branch, commit, initRepo, read, withRemote } from "./fixture/git"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
@@ -197,42 +196,6 @@ describe("Git trees", () => {
}),
)
it.live("caps batched tree patches, keeps per-file stats past the cap, and matches non-ASCII names", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(root.path))
const git = yield* Git.Service
const repository = yield* git.repo.discover(AbsolutePath.make(root.path))
if (!repository) throw new Error("Repository not found")
const before = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
const lines = Math.ceil(VcsPatch.MAX_TOTAL_PATCH_BYTES / 80) + 1
yield* Effect.promise(async () => {
await Bun.write(path.join(root.path, "a-small.txt"), "small\n")
await Bun.write(path.join(root.path, "b-large.txt"), `${"x".repeat(79)}\n`.repeat(lines))
await Bun.write(path.join(root.path, "c-binary.bin"), new Uint8Array([0, 1, 2, 3]))
await Bun.write(path.join(root.path, "a-caf\u00e9.txt"), "caf\u00e9\n")
})
const after = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
const diffs = yield* git.tree.diff({ repository, from: before, to: after, context: 0 })
expect(diffs.map((item) => [item.file, item.status, item.additions, item.deletions])).toEqual([
["a-caf\u00e9.txt", "added", 1, 0],
["a-small.txt", "added", 1, 0],
["b-large.txt", "added", lines, 0],
["c-binary.bin", "added", 0, 0],
])
// Patch headers are not NUL-delimited; a quoted (octal-escaped) header would orphan this chunk.
expect(diffs[0]?.patch).toContain("+caf\u00e9\n")
expect(diffs[1]?.patch).toContain("+small\n")
expect(diffs[2]?.patch).toBe(VcsPatch.emptyPatch("b-large.txt"))
expect(diffs[3]?.patch).toBe("")
expect(yield* git.tree.diff({ repository, from: before, to: after, paths: [] })).toEqual([])
}),
)
it.live("captures, compares, previews, and restores scoped trees", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
+1 -2
View File
@@ -66,8 +66,7 @@ export const registerToolPlugin = <R>(
const context = host({
...overrides,
session: {
...overrides.session,
hook: overrides.session?.hook ?? (() => Effect.succeed({ dispose: Effect.void })),
hook: () => Effect.succeed({ dispose: Effect.void }),
},
tool: {
transform: tools.transform,
-198
View File
@@ -1,198 +0,0 @@
import { $ } from "bun"
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Effect } from "effect"
import { Agent } from "@opencode/core/agent"
import { Bus } from "@opencode/core/bus"
import { Database } from "@opencode/core/database/database"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { LocationServiceMap } from "@opencode/core/location-service-map"
import { Model } from "@opencode/core/model"
import { Plugin } from "@opencode/core/plugin"
import { Provider } from "@opencode/core/provider"
import { AbsolutePath } from "@opencode/core/schema"
import { Session } from "@opencode/core/session"
import { SessionDiff } from "@opencode/core/session/diff"
import { SessionEvent } from "@opencode/core/session/event"
import { SessionExecution } from "@opencode/core/session/execution"
import { SessionInbox } from "@opencode/core/session/inbox"
import { SessionMessage } from "@opencode/core/session/message"
import { SessionProjector } from "@opencode/core/session/projector"
import { Snapshot } from "@opencode/core/snapshot"
import { Money } from "@opencode/schema/money"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Global } from "@opencode/util/global"
import { tempGlobalLayer } from "./fixture/global"
import { offlineModels } from "./fixture/models"
import { tmpdirScoped } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, Session.node, LocationServiceMap.node]),
[Global.node.replace(tempGlobalLayer), SessionExecution.node.replace(SessionExecution.noopLayer), offlineModels],
),
)
const summarize = (file: { file: string; status: string; additions: number; deletions: number }) => [
file.file,
file.status,
file.additions,
file.deletions,
]
describe("Session.diff", () => {
it.live(
"diffs the busy period containing a user message and ranges across later turns",
() =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
const directory = path.join(tmp.path, "project")
const write = (name: string, content: string) => () => Bun.write(path.join(directory, name), content)
yield* Effect.promise(async () => {
await fs.mkdir(directory)
await write("first.txt", "first\n")()
await write("second.txt", "second\n")()
await write("manual.txt", "manual\n")()
await $`git init -q`.cwd(directory).quiet()
await $`git -c core.fsmonitor=false add .`.cwd(directory).quiet()
})
const sessions = yield* Session.Service
const database = yield* Database.Service
const bus = yield* Bus.Service
const locations = yield* LocationServiceMap.Service
const created = yield* sessions.create({ location: { directory: AbsolutePath.make(directory) } })
const diff = (input?: { from?: SessionMessage.ID; to?: SessionMessage.ID }) =>
sessions
.diff({ sessionID: created.id, context: 0, ...input })
.pipe(Effect.map((files) => files.map(summarize)))
expect(yield* diff()).toEqual([])
yield* Effect.gen(function* () {
const plugins = yield* Plugin.Service
yield* plugins.awaitActivation
const snapshot = yield* Snapshot.Service
const usage = {
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
}
const prompt = Effect.fn(function* (text: string) {
const admitted = yield* sessions.prompt({ sessionID: created.id, text, resume: false })
yield* SessionInbox.promote(database.db, bus, created.id, "steer")
return admitted.id
})
const step = Effect.fn(function* (edit: () => Promise<unknown>, end: "recorded" | "unrecorded" | "running") {
const before = yield* snapshot.capture()
if (!before) throw new Error("Start snapshot missing")
const assistantMessageID = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.Step.Started, {
sessionID: created.id,
assistantMessageID,
agent: Agent.defaultID,
model: { id: Model.ID.make("test-model"), providerID: Provider.ID.make("test-provider") },
snapshot: before,
})
yield* Effect.promise(edit)
if (end === "running") return assistantMessageID
const after = end === "recorded" ? yield* snapshot.capture() : undefined
yield* bus.publish(SessionEvent.Step.Ended, {
sessionID: created.id,
assistantMessageID,
finish: "stop",
...usage,
snapshot: after,
files: after && before ? yield* snapshot.files({ from: before, to: after }) : undefined,
})
return assistantMessageID
})
const idle = (outcome: "succeeded" | "failed") =>
outcome === "succeeded"
? bus.publish(SessionEvent.Execution.Succeeded, { sessionID: created.id })
: bus.publish(SessionEvent.Execution.Failed, {
sessionID: created.id,
error: { type: "unknown", message: "failed" },
})
// Before any idle marker exists, a prompt's turn ends at the next prompt.
const first = yield* prompt("Edit the first file")
const firstStep = yield* step(write("first.txt", "first edited\n"), "recorded")
// Edits made while idle are not a turn's work, but a range spanning them still sees them.
yield* Effect.promise(write("manual.txt", "manual edited\n"))
const second = yield* prompt("Edit the second file")
yield* step(write("second.txt", "second edited\n"), "recorded")
expect(yield* diff()).toEqual([["second.txt", "modified", 1, 1]])
expect(yield* diff({ from: first })).toEqual([["first.txt", "modified", 1, 1]])
// Once markers exist, a turn spans a whole busy period, steers included; earlier history merges into the first one.
yield* idle("succeeded")
const third = yield* prompt("Add a third file")
yield* step(write("third.txt", "third\n"), "recorded")
const steer = yield* prompt("Also add a fourth file")
yield* step(write("fourth.txt", "fourth\n"), "recorded")
yield* idle("failed")
const busy = [
["fourth.txt", "added", 1, 0],
["third.txt", "added", 1, 0],
]
expect(yield* diff()).toEqual(busy)
expect(yield* diff({ from: steer })).toEqual(busy)
expect(yield* diff({ from: second })).toEqual([
["first.txt", "modified", 1, 1],
["manual.txt", "modified", 1, 1],
["second.txt", "modified", 1, 1],
])
expect(yield* diff({ from: first, to: third })).toEqual([
["first.txt", "modified", 1, 1],
["fourth.txt", "added", 1, 0],
["manual.txt", "modified", 1, 1],
["second.txt", "modified", 1, 1],
["third.txt", "added", 1, 0],
])
const full = yield* sessions.diff({ sessionID: created.id, from: first })
expect(full[0]?.patch).toContain("-first\n+first edited\n")
expect(yield* diff({ from: steer, to: second }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.TurnRangeError",
field: "to",
})
expect(yield* diff({ from: firstStep }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.TurnRangeError",
field: "from",
})
expect(yield* diff({ from: SessionMessage.ID.create() }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.MessageNotFoundError",
})
// A completed step without an end snapshot falls back to the last recorded end.
yield* prompt("Edit both files again")
yield* step(write("first.txt", "first edited twice\n"), "recorded")
yield* step(write("second.txt", "second edited twice\n"), "unrecorded")
yield* idle("succeeded")
expect(yield* diff()).toEqual([["first.txt", "modified", 1, 1]])
// Only a step still running in the active session compares against the working copy.
yield* prompt("Delete the manual file")
yield* step(() => fs.rm(path.join(directory, "manual.txt")), "running")
expect(yield* diff()).toEqual([])
const session = yield* sessions.get(created.id)
const live = yield* SessionDiff.turn(database.db, locations, { session, active: true, context: 0 })
expect(live.map(summarize)).toEqual([["manual.txt", "deleted", 0, 1]])
// Reverting removes later history, markers included; a fork keeps the copied turns.
yield* sessions.revert.stage({ sessionID: created.id, messageID: steer, files: false })
yield* sessions.revert.commit(created.id)
expect(yield* diff()).toEqual([["third.txt", "added", 1, 0]])
expect(yield* diff({ from: steer }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.MessageNotFoundError",
})
const forked = yield* sessions.fork({ sessionID: created.id, boundary: { type: "through" } })
expect((yield* sessions.diff({ sessionID: forked.id, context: 0 })).map(summarize)).toEqual([
["third.txt", "added", 1, 0],
])
}).pipe(Effect.provide(LocationServiceMap.Service.get(created.location)))
}),
// Real Location/plugin startup and Git snapshots can exceed five seconds under CI load.
{ timeout: 30_000 },
)
})
+2 -3
View File
@@ -561,9 +561,7 @@ describe("SessionRestart background recovery", () => {
expect(yield* restarted.pendingBackground).toEqual([])
expect(yield* SessionInbox.list(database.db, sessionID)).toHaveLength(delivered ? 0 : 1)
yield* SessionInbox.promote(database.db, bus, sessionID, "steer")
// Recovery ends a busy period, so an idle marker follows the notification.
const messages = (yield* sessions.messages({ sessionID })).filter((message) => message.type !== "idle")
expect(messages).toMatchObject([
expect(yield* sessions.messages({ sessionID })).toMatchObject([
{
id: background.notificationID,
type: "synthetic",
@@ -571,6 +569,7 @@ describe("SessionRestart background recovery", () => {
metadata: { state: "completed" },
},
])
expect(yield* sessions.messages({ sessionID })).toHaveLength(1)
}),
)
}
+1 -43
View File
@@ -9,14 +9,11 @@ import { Formatter } from "@opencode/core/formatter"
import { FileMutation } from "@opencode/core/file-mutation"
import { Location } from "@opencode/core/location"
import { FileAccess } from "@opencode/core/file-access"
import { Model } from "@opencode/core/model"
import { Permission } from "@opencode/core/permission"
import { Provider } from "@opencode/core/provider"
import { AbsolutePath } from "@opencode/core/schema"
import { Session } from "@opencode/core/session"
import { Tool } from "@opencode/core/tool"
import { PatchTool } from "@opencode/core/tool/plugin/patch"
import type { SessionHooks } from "@opencode/plugin/effect/session"
import { transformEnvironmentFiles } from "./fixture/environment"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
@@ -25,20 +22,9 @@ import { testEffect } from "./lib/effect"
import { permissionLayer } from "./lib/permission"
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
const sessionHooks = new Map<string, (event: SessionHooks["context"]) => Effect.Effect<void>>()
const patchToolNode = makeLocationNode({
name: "test/patch-tool-plugin",
layer: Layer.effectDiscard(
registerToolPlugin(PatchTool.Plugin, {
session: {
hook: (name, callback) =>
Effect.sync(() => {
sessionHooks.set(name, callback as (event: SessionHooks["context"]) => Effect.Effect<void>)
return { dispose: Effect.void }
}),
},
}),
),
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
deps: [
Tool.node,
FileAccess.node,
@@ -167,34 +153,6 @@ const withTempTool = <A, E, R>(body: (directory: string, registry: Tool.Interfac
)
describe("PatchTool", () => {
it.live("selects the same edit tools for compaction and generate requests as the agent loop", () =>
withTempTool(() =>
Effect.gen(function* () {
const event = (id: string): SessionHooks["context"] => ({
sessionID,
agent: toolIdentity.agent,
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make(id) }),
system: [],
messages: [],
tools: Object.fromEntries(
["patch", "edit", "write", "read"].map((name) => [name, { description: name, input: { type: "object" } }]),
),
options: {},
})
for (const name of ["context", "compaction", "generate"]) {
const hook = sessionHooks.get(name)
expect(hook).toBeDefined()
const claude = event("claude-sonnet-4")
yield* hook!(claude)
expect(Object.keys(claude.tools)).toEqual(["edit", "write", "read"])
const gpt = event("gpt-5")
yield* hook!(gpt)
expect(Object.keys(gpt.tools)).toEqual(["patch", "read"])
}
}),
),
)
it.live("registers and sequentially applies add, update, and delete hunks", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode/desktop",
"private": true,
"version": "2.0.3",
"version": "2.0.2",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/enterprise",
"version": "2.0.3",
"version": "2.0.2",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/function",
"version": "2.0.3",
"version": "2.0.2",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.3",
"version": "2.0.2",
"name": "@opencode/http-recorder",
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
"type": "module",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/httpapi-codegen",
"version": "2.0.3",
"version": "2.0.2",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/latex",
"version": "2.0.3",
"version": "2.0.2",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/merman",
"version": "2.0.3",
"version": "2.0.2",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/plugin-browser",
"version": "2.0.3",
"version": "2.0.2",
"description": "OpenCode's desktop browser plugin",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/plugin",
"version": "2.0.3",
"version": "2.0.2",
"type": "module",
"license": "MIT",
"scripts": {
-181
View File
@@ -3242,152 +3242,6 @@
"summary": "Get session context"
}
},
"/api/session/{sessionID}/diff": {
"get": {
"tags": ["session"],
"operationId": "v2.session.diff",
"parameters": [
{
"name": "sessionID",
"in": "path",
"schema": {
"type": "string",
"pattern": "^ses"
},
"required": true
},
{
"name": "from",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string",
"pattern": "^msg_"
},
{
"type": "null"
}
],
"description": "User message whose turn to diff. Defaults to the turn of the newest user message."
},
"required": false
},
{
"name": "to",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string",
"pattern": "^msg_"
},
{
"type": "null"
}
],
"description": "Later user message whose turn ends the range. Defaults to the turn of `from` alone."
},
"required": false
},
{
"name": "context",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Unchanged lines around each hunk. Omit for full-file patches."
},
"required": false
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/FileDiff.Info"
}
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"404": {
"description": "MessageNotFoundError | SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/MessageNotFoundErrorEncoded"
},
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
]
}
}
}
},
"500": {
"description": "UnknownError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnknownErrorEncoded"
}
}
}
}
},
"description": "Structured per-file diffs of the files a turn changed. A turn runs from the first prompt after the session was last idle until its next idle marker, so prompts steered in while it was busy belong to the same turn; `to` extends the range through a later turn. Compares the range's first recorded snapshot with its last; a step still running in the active session compares against the working copy. Ranges that span a location change are rejected. In sessions without any idle marker, a prompt's turn spans until the next user message.",
"summary": "Diff session turns"
}
},
"/api/session/{sessionID}/inbox": {
"get": {
"tags": ["session"],
@@ -18567,38 +18421,6 @@
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
"additionalProperties": false
},
"Session.Message.Idle": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^msg_"
},
"metadata": {
"type": "object"
},
"time": {
"type": "object",
"properties": {
"created": {
"type": "number"
}
},
"required": ["created"],
"additionalProperties": false
},
"type": {
"type": "string",
"enum": ["idle"]
},
"outcome": {
"type": "string",
"enum": ["succeeded", "failed", "interrupted"]
}
},
"required": ["id", "time", "type", "outcome"],
"additionalProperties": false
},
"Session.Message.Info": {
"anyOf": [
{
@@ -18630,9 +18452,6 @@
},
{
"$ref": "#/components/schemas/Session.Message.Compaction"
},
{
"$ref": "#/components/schemas/Session.Message.Idle"
}
]
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/protocol",
"version": "2.0.3",
"version": "2.0.2",
"type": "module",
"license": "MIT",
"repository": {
-26
View File
@@ -31,7 +31,6 @@ import { Permission } from "@opencode/schema/permission"
import { Location } from "@opencode/schema/location"
import { SessionEvent } from "@opencode/schema/session-event"
import { EventLog } from "@opencode/schema/event-log"
import { FileDiff } from "@opencode/schema/file-diff"
const ParentIDFilter = Schema.Union([
Session.ID,
@@ -524,31 +523,6 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
}),
),
)
.add(
HttpApiEndpoint.get("session.diff", "/api/session/:sessionID/diff", {
params: { sessionID: Session.ID },
query: Schema.Struct({
from: Schema.optional(SessionMessage.ID).annotate({
description: "User message whose turn to diff. Defaults to the turn of the newest user message.",
}),
to: Schema.optional(SessionMessage.ID).annotate({
description: "Later user message whose turn ends the range. Defaults to the turn of `from` alone.",
}),
context: Schema.NumberFromString.pipe(Schema.decodeTo(NonNegativeInt), Schema.optional).annotate({
description: "Unchanged lines around each hunk. Omit for full-file patches.",
}),
}),
success: Schema.Struct({ data: Schema.Array(FileDiff.Info) }),
error: [InvalidRequestError, MessageNotFoundError, SessionNotFoundError, UnknownError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.diff",
summary: "Diff session turns",
description:
"Structured per-file diffs of the files a turn changed. A turn runs from the first prompt after the session was last idle until its next idle marker, so prompts steered in while it was busy belong to the same turn; `to` extends the range through a later turn. Compares the range's first recorded snapshot with its last; a step still running in the active session compares against the working copy. Ranges that span a location change are rejected. In sessions without any idle marker, a prompt's turn spans until the next user message.",
}),
),
)
.add(
HttpApiEndpoint.get("session.inbox.list", "/api/session/:sessionID/inbox", {
params: { sessionID: Session.ID },
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/schema",
"version": "2.0.3",
"version": "2.0.2",
"type": "module",
"license": "MIT",
"repository": {
-14
View File
@@ -280,18 +280,6 @@ export const Compaction = Schema.Union([CompactionRunning, CompactionCompleted,
)
export type Compaction = CompactionRunning | CompactionCompleted | CompactionFailed
/**
* Marks the Session going idle: every step since the previous marker belongs to
* one turn, including prompts steered in while it was busy. A shutdown does not
* record one, since the resumed execution continues the same turn.
*/
export interface Idle extends Schema.Schema.Type<typeof Idle> {}
export const Idle = Schema.Struct({
...Base,
type: Schema.tag("idle"),
outcome: Schema.Literals(["succeeded", "failed", "interrupted"]),
}).annotate({ identifier: "Session.Message.Idle" })
export const Info = Schema.Union([
AgentSelected,
ModelSelected,
@@ -303,7 +291,6 @@ export const Info = Schema.Union([
Shell,
Assistant,
Compaction,
Idle,
]).annotate({ identifier: "Session.Message.Info" })
export type Info =
| AgentSelected
@@ -316,5 +303,4 @@ export type Info =
| Shell
| Assistant
| Compaction
| Idle
export type Type = Info["type"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.3",
"version": "2.0.2",
"name": "@opencode/sdk",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/server",
"version": "2.0.3",
"version": "2.0.2",
"type": "module",
"license": "MIT",
"repository": {
+1 -23
View File
@@ -1,6 +1,5 @@
import { Session } from "@opencode/core/session"
import type { Snapshot } from "@opencode/core/snapshot"
import { MessageNotFoundError, SessionNotFoundError, UnknownError } from "@opencode/protocol/errors"
import { SessionNotFoundError, UnknownError } from "@opencode/protocol/errors"
import { Effect } from "effect"
export function missingSession(error: Session.NotFoundError) {
@@ -10,14 +9,6 @@ export function missingSession(error: Session.NotFoundError) {
})
}
export function missingMessage(error: Session.MessageNotFoundError) {
return new MessageNotFoundError({
sessionID: error.sessionID,
messageID: error.messageID,
message: `Message not found: ${error.messageID}`,
})
}
export function failedMessageDecode(error: Session.MessageDecodeError) {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError("failed to decode session message").pipe(
@@ -27,16 +18,3 @@ export function failedMessageDecode(error: Session.MessageDecodeError) {
),
)
}
/** Snapshot repositories are host state clients cannot repair, so surface only a log reference. */
export function failedSnapshot(operation: string, sessionID: Session.ID) {
return (error: Snapshot.Error) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError(`failed to ${operation}`, { cause: error }).pipe(
Effect.annotateLogs({ ref, sessionID }),
Effect.andThen(
Effect.fail(new UnknownError({ message: "Unexpected server error. Check server logs for details.", ref })),
),
)
}
}
+53 -32
View File
@@ -17,9 +17,10 @@ import {
ServiceUnavailableError,
SessionBusyError,
SkillNotFoundError,
UnknownError,
} from "@opencode/protocol/errors"
import { AbsolutePath } from "@opencode/core/schema"
import { failedMessageDecode, failedSnapshot, missingMessage, missingSession } from "./session-error"
import { failedMessageDecode, missingSession } from "./session-error"
const DefaultSessionsLimit = 50
@@ -212,7 +213,15 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
return {
data: yield* session.fork({ sessionID: ctx.params.sessionID, boundary: ctx.payload.boundary }).pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag("Session.MessageNotFoundError", missingMessage),
Effect.catchTag(
"Session.MessageNotFoundError",
(error) =>
new MessageNotFoundError({
sessionID: error.sessionID,
messageID: error.messageID,
message: `Message not found: ${error.messageID}`,
}),
),
Effect.catchTag(
"Session.ForkEmptyError",
(error) => new InvalidRequestError({ message: error.message, kind: "empty_session" }),
@@ -440,14 +449,32 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
files: ctx.payload.files,
})
return {
data: yield* session.revert
.stage({ ...ctx.params, ...ctx.payload })
.pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag("Session.MessageNotFoundError", missingMessage),
Effect.catchTag("Session.BusyError", busySession),
Effect.catchTag("Snapshot.Error", failedSnapshot("stage session revert", ctx.params.sessionID)),
data: yield* session.revert.stage({ ...ctx.params, ...ctx.payload }).pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag(
"Session.MessageNotFoundError",
(error) =>
new MessageNotFoundError({
sessionID: error.sessionID,
messageID: error.messageID,
message: `Message not found: ${error.messageID}`,
}),
),
Effect.catchTag("Session.BusyError", busySession),
Effect.catchTag("Snapshot.Error", (error) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError("failed to stage session revert", { cause: error }).pipe(
Effect.andThen(
Effect.fail(
new UnknownError({
message: "Unexpected server error. Check server logs for details.",
ref,
}),
),
),
)
}),
),
}
}),
)
@@ -455,13 +482,23 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
"session.revert.clear",
Effect.fn(function* (ctx) {
yield* Effect.log("session.revert.clear", { sessionID: ctx.params.sessionID })
yield* session.revert
.clear(ctx.params.sessionID)
.pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag("Session.BusyError", busySession),
Effect.catchTag("Snapshot.Error", failedSnapshot("clear session revert", ctx.params.sessionID)),
)
yield* session.revert.clear(ctx.params.sessionID).pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag("Session.BusyError", busySession),
Effect.catchTag("Snapshot.Error", (error) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError("failed to clear session revert", { cause: error }).pipe(
Effect.andThen(
Effect.fail(
new UnknownError({
message: "Unexpected server error. Check server logs for details.",
ref,
}),
),
),
)
}),
)
return HttpApiSchema.NoContent.make()
}),
)
@@ -491,22 +528,6 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
}
}),
)
.handle(
"session.diff",
Effect.fn(function* (ctx) {
return {
data: yield* session.diff({ sessionID: ctx.params.sessionID, ...ctx.query }).pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag("Session.MessageNotFoundError", missingMessage),
Effect.catchTag(
"Session.TurnRangeError",
(error) => new InvalidRequestError({ message: error.message, field: error.field }),
),
Effect.catchTag("Snapshot.Error", failedSnapshot("diff session turn", ctx.params.sessionID)),
),
}
}),
)
.handle(
"session.inbox.list",
Effect.fn(function* (ctx) {
-98
View File
@@ -1,98 +0,0 @@
import { expect, setDefaultTimeout } from "bun:test"
import { Agent } from "@opencode/core/agent"
import { Bus } from "@opencode/core/bus"
import { Model } from "@opencode/core/model"
import { Provider } from "@opencode/core/provider"
import { Session } from "@opencode/core/session"
import { SessionEvent } from "@opencode/core/session/event"
import { SessionExecution } from "@opencode/core/session/execution"
import { SessionMessage } from "@opencode/core/session/message"
import { Money } from "@opencode/schema/money"
import { makeGlobalNode } from "@opencode/util/effect/app-node"
import { Effect, Layer } from "effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { ServerFetch } from "../src/fetch"
setDefaultTimeout(30_000)
it.live("serves turn diffs by user message with range validation", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-session-diff-")))
const ids = { user: SessionMessage.ID.create(), assistant: SessionMessage.ID.create() }
// Deliver the prompt and one step the way the runner would, without a model.
const execution = Layer.effect(
SessionExecution.Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
return SessionExecution.Service.of({
active: Effect.succeed(new Set()),
isActive: () => Effect.succeed(false),
resume: () => Effect.void,
wake: (sessionID) =>
Effect.gen(function* () {
yield* bus.publish(SessionEvent.InboxDelivered, { sessionID, inboxID: ids.user })
yield* bus.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID: ids.assistant,
agent: Agent.defaultID,
model: { id: Model.ID.make("model"), providerID: Provider.ID.make("provider") },
})
yield* bus.publish(SessionEvent.Step.Ended, {
sessionID,
assistantMessageID: ids.assistant,
finish: "stop",
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
})
}),
interrupt: () => Effect.succeed(false),
awaitIdle: () => Effect.void,
})
}),
)
const handler = yield* ServerFetch.make(
{
app: { version: "test-version" },
database: { path: ":memory:" },
fs: { filewatcher: false },
models: { fetch: false },
},
{
overrides: [
SessionExecution.node.replace(
makeGlobalNode({ service: SessionExecution.Service, layer: execution, deps: [Bus.node] }),
),
],
},
)
const request = (path: string, body?: unknown) =>
Effect.promise(async () => {
const response = await handler(
new Request(`http://opencode.local${path}`, {
method: body === undefined ? "GET" : "POST",
headers: body === undefined ? undefined : { "content-type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
}),
)
return { status: response.status, body: (await response.json()) as Record<string, unknown> }
})
const created = yield* request("/api/session", { location: { directory: tmp.path } })
const sessionID = Session.ID.make((created.body.data as { id: string }).id)
const diff = (query = "") => request(`/api/session/${sessionID}/diff${query}`)
expect(yield* diff()).toEqual({ status: 200, body: { data: [] } })
expect((yield* request(`/api/session/${sessionID}/prompt`, { id: ids.user, text: "prompt" })).status).toBe(200)
// Not a git repository, so steps record no snapshots and the turn has no diff.
expect(yield* diff(`?from=${ids.user}&context=3`)).toEqual({ status: 200, body: { data: [] } })
expect(yield* diff(`?from=${ids.assistant}`)).toMatchObject({
status: 400,
body: { _tag: "InvalidRequestError", field: "from" },
})
expect(yield* diff(`?from=${SessionMessage.ID.create()}`)).toMatchObject({
status: 404,
body: { _tag: "MessageNotFoundError" },
})
expect((yield* request(`/api/session/${Session.ID.create()}/diff`)).status).toBe(404)
}),
)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/session-ui",
"version": "2.0.3",
"version": "2.0.2",
"private": true,
"type": "module",
"license": "MIT",
@@ -16,7 +16,7 @@ export { TimelineRow, type PartGroup, type PartRef, type TimelineRowMap }
export type ReasoningMode = "hidden" | "compact" | "full"
type Notice = Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" | "idle" }>
type Notice = Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" }>
type Entry = { type: "assistant"; message: SessionMessageAssistant } | { type: "notice"; message: Notice }
type Content = SessionMessageAssistant["content"][number]
type GroupRow = Extract<TimelineRow.TimelineRow, { _tag: "AssistantPart" }>
@@ -763,8 +763,7 @@ function record(value: unknown): value is Record<string, unknown> {
}
function isNotice(message: SessionMessageInfo): message is Notice {
if (message.type === "user" || message.type === "assistant" || message.type === "shell" || message.type === "idle")
return false
if (message.type === "user" || message.type === "assistant" || message.type === "shell") return false
if (message.type !== "synthetic") return true
return !!message.description?.trim() || timelineNoticeRequired(message)
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/simulation",
"version": "2.0.3",
"version": "2.0.2",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-app",
"version": "2.0.3",
"version": "2.0.2",
"private": true,
"type": "module",
"license": "MIT",
@@ -10,7 +10,7 @@ export type HeaderLink = { href: string; label: string }
export const githubLink = {
href: "https://github.com/anomalyco/opencode",
apiHref: "https://api.github.com/repos/anomalyco/opencode",
fallbackStars: "205K",
fallbackStars: "207K",
}
export const themePreferences = ["dark", "light", "system"] as const
export const themeStorageKey = "opencode:stats-theme"
@@ -19,7 +19,6 @@ export type ThemePreference = (typeof themePreferences)[number]
const compactNumberFormatter = new Intl.NumberFormat("en", {
notation: "compact",
maximumFractionDigits: 0,
roundingIncrement: 5,
})
export const getGitHubStars = query(async () => {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-core",
"version": "2.0.3",
"version": "2.0.2",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-server",
"version": "2.0.3",
"version": "2.0.2",
"private": true,
"type": "module",
"license": "MIT",

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