mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-13 12:26:20 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f289621fe | ||
|
|
7a31b5c0f7 | ||
|
|
fb3c10ca66 | ||
|
|
c43cfccc4e | ||
|
|
c5aa7d7e34 | ||
|
|
9c8a4ea4ff | ||
|
|
c82340a97b | ||
|
|
dbc63955b0 | ||
|
|
2816d1c849 | ||
|
|
61d812fcd6 | ||
|
|
e47b9b5453 | ||
|
|
21dac524f3 |
@@ -31,7 +31,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@opencode/ai",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -53,7 +53,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
@@ -112,7 +112,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"bin": {
|
||||
"opencode2": "./bin/opencode2.cjs",
|
||||
},
|
||||
@@ -176,7 +176,7 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@opencode/protocol": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -202,7 +202,7 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
@@ -216,7 +216,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"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.2",
|
||||
"version": "2.0.3",
|
||||
"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.2",
|
||||
"version": "2.0.3",
|
||||
"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.2",
|
||||
"version": "2.0.3",
|
||||
"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.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -340,7 +340,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode/core",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"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.2",
|
||||
"version": "2.0.3",
|
||||
"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.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/sdk": "1.18.21",
|
||||
@@ -501,7 +501,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -517,7 +517,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode/http-recorder",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
},
|
||||
@@ -536,7 +536,7 @@
|
||||
},
|
||||
"packages/httpapi-codegen": {
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
@@ -549,7 +549,7 @@
|
||||
},
|
||||
"packages/latex": {
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -563,7 +563,7 @@
|
||||
},
|
||||
"packages/merman": {
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -578,7 +578,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode/ai": "workspace:*",
|
||||
@@ -617,7 +617,7 @@
|
||||
},
|
||||
"packages/plugin-browser": {
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -647,7 +647,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@opencode/schema": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -662,7 +662,7 @@
|
||||
},
|
||||
"packages/schema": {
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -686,7 +686,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@opencode/sdk",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -707,7 +707,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
@@ -729,7 +729,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
@@ -764,7 +764,7 @@
|
||||
},
|
||||
"packages/simulation": {
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@opencode/ai": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -784,7 +784,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -818,7 +818,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"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.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -883,7 +883,7 @@
|
||||
},
|
||||
"packages/theme": {
|
||||
"name": "@opencode/theme",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@opentui/core": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -897,7 +897,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode/tui",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -932,7 +932,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -967,7 +967,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode/util",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -1000,7 +1000,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode/web",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1041,7 +1041,7 @@
|
||||
},
|
||||
"services/update": {
|
||||
"name": "@opencode/update",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"jose": "6.0.11",
|
||||
"semver": "catalog:",
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"name": "@opencode/ai",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -282,12 +282,16 @@ const removeEmptyToolInputKeys = (input: unknown): unknown => {
|
||||
const lowerToolCall = (part: ToolCallPart): BedrockToolUseBlock => ({
|
||||
toolUse: {
|
||||
toolUseId: part.id,
|
||||
name: part.name,
|
||||
// Models can emit names that Converse rejects when replayed in history.
|
||||
name: part.name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64) || "_",
|
||||
input: removeEmptyToolInputKeys(part.input),
|
||||
},
|
||||
})
|
||||
|
||||
const lowerToolResultContent = Effect.fn("BedrockConverse.lowerToolResultContent")(function* (part: ToolResultPart) {
|
||||
const lowerToolResultContent = Effect.fn("BedrockConverse.lowerToolResultContent")(function* (
|
||||
part: ToolResultPart,
|
||||
documentNames: Set<string>,
|
||||
) {
|
||||
if (part.result.type === "text" || part.result.type === "error")
|
||||
return [{ text: ProviderShared.toolResultText(part) }]
|
||||
if (part.result.type === "json") return [{ json: part.result.value }]
|
||||
@@ -298,22 +302,28 @@ 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,
|
||||
})
|
||||
content.push(media)
|
||||
const media = yield* BedrockMedia.lower(
|
||||
{
|
||||
type: "media",
|
||||
mediaType: item.mime,
|
||||
data: item.uri,
|
||||
filename: item.name,
|
||||
},
|
||||
documentNames,
|
||||
)
|
||||
content.push(...media)
|
||||
}
|
||||
return content
|
||||
})
|
||||
|
||||
const lowerToolResult = Effect.fn("BedrockConverse.lowerToolResult")(function* (part: ToolResultPart) {
|
||||
const lowerToolResult = Effect.fn("BedrockConverse.lowerToolResult")(function* (
|
||||
part: ToolResultPart,
|
||||
documentNames: Set<string>,
|
||||
) {
|
||||
return {
|
||||
toolResult: {
|
||||
toolUseId: part.id,
|
||||
content: yield* lowerToolResultContent(part),
|
||||
content: yield* lowerToolResultContent(part, documentNames),
|
||||
status: part.result.type === "error" ? "error" : "success",
|
||||
},
|
||||
} satisfies BedrockToolResultBlock
|
||||
@@ -324,6 +334,7 @@ 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) {
|
||||
@@ -347,7 +358,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
continue
|
||||
}
|
||||
if (part.type === "media") {
|
||||
content.push(yield* BedrockMedia.lower(part))
|
||||
content.push(...(yield* BedrockMedia.lower(part, documentNames)))
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -400,7 +411,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))
|
||||
content.push(yield* lowerToolResult(part, documentNames))
|
||||
const cachePoint = BedrockCache.block(breakpoints, part.cache)
|
||||
if (cachePoint) content.push(cachePoint)
|
||||
}
|
||||
|
||||
@@ -57,6 +57,25 @@ 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(
|
||||
@@ -72,19 +91,26 @@ 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) {
|
||||
export const lower = Effect.fn("BedrockMedia.lower")(function* (part: MediaPart, documentNames: Set<string>) {
|
||||
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) {
|
||||
if (!part.filename)
|
||||
return yield* ProviderShared.invalidRequest("Bedrock Converse document media requires a filename")
|
||||
return documentBlock(part.filename, documentFormat, yield* mediaBase64(part))
|
||||
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]
|
||||
}
|
||||
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, Stream } from "effect"
|
||||
import { Effect, Encoding, Ref, Schema, Stream } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import {
|
||||
CacheHint,
|
||||
@@ -11,9 +11,11 @@ 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"
|
||||
@@ -394,6 +396,45 @@ 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* () {
|
||||
@@ -797,6 +838,57 @@ 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(
|
||||
@@ -1673,29 +1765,141 @@ describe("Bedrock Converse route", () => {
|
||||
role: "user",
|
||||
content: [
|
||||
{ text: "Summarize these documents." },
|
||||
{ document: { format: "pdf", name: "report.pdf", source: { bytes: "UERGREFUQQ==" } } },
|
||||
{ document: { format: "csv", name: "data.csv", source: { bytes: "Q1NWREFUQQ==" } } },
|
||||
{ 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==" } } },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
;[
|
||||
{
|
||||
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==" } }
|
||||
|
||||
it.effect("requires names for document media", () =>
|
||||
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", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [Message.user({ type: "media", mediaType: "application/pdf", data: "UERGREFUQQ==" })],
|
||||
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,
|
||||
})),
|
||||
]),
|
||||
],
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error.message).toContain("document media requires a filename")
|
||||
)
|
||||
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=" } } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("passes named document-only messages through for provider validation", () =>
|
||||
it.effect("annotates renamed document-only messages with their original filename", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
@@ -1715,12 +1919,44 @@ describe("Bedrock Converse route", () => {
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [{ document: { format: "pdf", name: "report.pdf", source: { bytes: "UERGREFUQQ==" } } }],
|
||||
content: [
|
||||
{ text: 'Attached file "report.pdf" has document label "report".' },
|
||||
{ document: { format: "pdf", name: "report", 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(
|
||||
@@ -1740,7 +1976,7 @@ describe("Bedrock Converse route", () => {
|
||||
type: "file",
|
||||
uri: "data:application/pdf;base64,UERGREFUQQ==",
|
||||
mime: "application/pdf",
|
||||
name: "report",
|
||||
name: "report.pdf",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -1763,6 +1999,7 @@ 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==" } } },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
||||
@@ -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, type ThemeMode } from "@opentui/core"
|
||||
import { createCliRenderer, RGBA, TextAttributes, type CliRenderer } from "@opentui/core"
|
||||
import { render, useTerminalDimensions } from "@opentui/solid"
|
||||
import { OPENCODE_VERSION } from "../version"
|
||||
import { registerOpencodeSpinner } from "@opencode/tui/component/register-spinner"
|
||||
@@ -30,17 +30,11 @@ const completionHold = 650
|
||||
export type Handle = {
|
||||
readonly begin: (from?: string) => boolean
|
||||
readonly loading: () => void
|
||||
readonly finish: () => Promise<Handoff | undefined>
|
||||
readonly finish: () => Promise<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 {
|
||||
@@ -72,7 +66,7 @@ export const make = (): Handle => {
|
||||
|
||||
type Session = {
|
||||
readonly loading: () => Promise<void>
|
||||
readonly finish: () => Promise<Handoff>
|
||||
readonly finish: () => Promise<undefined>
|
||||
readonly fail: (message: string) => Promise<void>
|
||||
readonly close: () => Promise<void>
|
||||
}
|
||||
@@ -83,7 +77,6 @@ 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,
|
||||
@@ -101,20 +94,17 @@ async function open(from?: string): Promise<Session> {
|
||||
externalOutputMode: "capture-stdout",
|
||||
consoleMode: "disabled",
|
||||
})
|
||||
const terminalMode = renderer.waitForThemeMode(1000).catch(() => null)
|
||||
await render(
|
||||
() => (
|
||||
<Show when={visible()}>
|
||||
<UpdateFooter
|
||||
from={from}
|
||||
active={active}
|
||||
outcome={outcome}
|
||||
failure={failure}
|
||||
animating={animating}
|
||||
renderer={renderer}
|
||||
onOutcomeSettled={() => resolveOutcome?.()}
|
||||
/>
|
||||
</Show>
|
||||
<UpdateFooter
|
||||
from={from}
|
||||
active={active}
|
||||
outcome={outcome}
|
||||
failure={failure}
|
||||
animating={animating}
|
||||
renderer={renderer}
|
||||
onOutcomeSettled={() => resolveOutcome?.()}
|
||||
/>
|
||||
),
|
||||
renderer,
|
||||
).catch((error) => {
|
||||
@@ -148,10 +138,8 @@ 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()
|
||||
@@ -174,18 +162,8 @@ async function open(from?: string): Promise<Session> {
|
||||
await waitForStage()
|
||||
await transitionTo("success", completionHold)
|
||||
})
|
||||
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),
|
||||
}
|
||||
await close()
|
||||
return undefined
|
||||
},
|
||||
fail: (message) =>
|
||||
settle(async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -18,6 +18,7 @@ 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"
|
||||
@@ -36,7 +37,6 @@ 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,6 +361,15 @@ 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> = (
|
||||
@@ -1150,6 +1159,7 @@ 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,6 +68,8 @@ import type {
|
||||
SessionRevertCommitOutput,
|
||||
SessionContextInput,
|
||||
SessionContextOutput,
|
||||
SessionDiffInput,
|
||||
SessionDiffOutput,
|
||||
SessionInboxListInput,
|
||||
SessionInboxListOutput,
|
||||
SessionInboxCancelInput,
|
||||
@@ -599,6 +601,17 @@ 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(
|
||||
@@ -738,6 +751,7 @@ const adaptGroupSession = (raw: RawClient["server.session"]) => ({
|
||||
commit: EndpointSessionRevertCommit(raw),
|
||||
},
|
||||
context: EndpointSessionContext(raw),
|
||||
diff: EndpointSessionDiff(raw),
|
||||
inbox: {
|
||||
list: EndpointSessionInboxList(raw),
|
||||
cancel: EndpointSessionInboxCancel(raw),
|
||||
|
||||
@@ -62,6 +62,8 @@ import type {
|
||||
SessionRevertCommitOutput,
|
||||
SessionContextInput,
|
||||
SessionContextOutput,
|
||||
SessionDiffInput,
|
||||
SessionDiffOutput,
|
||||
SessionInboxListInput,
|
||||
SessionInboxListOutput,
|
||||
SessionInboxCancelInput,
|
||||
@@ -849,6 +851,18 @@ 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,6 +147,14 @@ 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"
|
||||
@@ -2210,6 +2218,7 @@ export type SessionMessageInfo =
|
||||
| SessionMessageShell
|
||||
| SessionMessageAssistant
|
||||
| SessionMessageCompaction
|
||||
| SessionMessageIdle
|
||||
|
||||
export type SessionMessageContentUpdated = {
|
||||
id: string
|
||||
@@ -3222,6 +3231,13 @@ 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"]
|
||||
@@ -3532,6 +3548,13 @@ 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"]
|
||||
@@ -3842,6 +3865,13 @@ 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"]
|
||||
@@ -4331,6 +4361,27 @@ 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"]
|
||||
|
||||
@@ -1028,6 +1028,18 @@ 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)
|
||||
|
||||
@@ -440,3 +440,9 @@ 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,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"description": "Effect-native confined code execution over schema-described tools",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -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, InterpreterRuntimeError, ProgramThrow, sourceLocation } from "./model.js"
|
||||
import { type AstNode, formatLocation, PendingThrow, ProgramThrow, sourceLocation, typeError } 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 InterpreterRuntimeError) {
|
||||
if (error instanceof PendingThrow) {
|
||||
return {
|
||||
kind: error.kind,
|
||||
message: `${error.message}${formatLocation(error.node)}`,
|
||||
@@ -43,14 +43,15 @@ 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)
|
||||
@@ -81,14 +82,47 @@ export const normalizeError = (error: unknown): Diagnostic => {
|
||||
}
|
||||
}
|
||||
|
||||
export const caughtErrorValue = <R>(runner: Runner<R>, thrown: unknown): unknown => {
|
||||
/**
|
||||
* 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 => {
|
||||
if (thrown instanceof ProgramThrow) return thrown.value
|
||||
const prototypes = runner.prototypes
|
||||
if (thrown instanceof InterpreterRuntimeError) return createErrorValue(prototypes[thrown.type], thrown.message)
|
||||
if (thrown instanceof PendingThrow) {
|
||||
if (thrown.value === undefined) {
|
||||
thrown.value = createErrorValue(prototypes[thrown.type], thrown.message)
|
||||
thrown.value.host = thrown
|
||||
}
|
||||
return thrown.value
|
||||
}
|
||||
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>,
|
||||
@@ -104,13 +138,10 @@ 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], node)
|
||||
if (cursor === undefined) {
|
||||
throw new InterpreterRuntimeError("new AggregateError(...) expects a synchronous iterable of errors.", node)
|
||||
}
|
||||
const cursor = yield* runner.syncIterator(args[0])
|
||||
if (cursor === undefined) throw typeError("new AggregateError(...) expects a synchronous iterable of errors.")
|
||||
const errors: Array<unknown> = []
|
||||
while (true) {
|
||||
const step = yield* cursor.next
|
||||
@@ -125,34 +156,21 @@ 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, node: AstNode) => {
|
||||
const construct = (args: Array<unknown>, newTarget: Callable) => {
|
||||
const proto = prototypeFrom(newTarget, prototype)
|
||||
return type === "AggregateError"
|
||||
? constructAggregateErrorValue(runner, args, proto, node)
|
||||
? constructAggregateErrorValue(runner, args, proto)
|
||||
: 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, node) => construct(args, ctor, node),
|
||||
call: (_, args) => construct(args, ctor),
|
||||
construct,
|
||||
})
|
||||
if (type === "Error") {
|
||||
methods(protos, prototype, [
|
||||
[
|
||||
"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}`
|
||||
},
|
||||
],
|
||||
["toString", 0, (thisValue) => errorToString(receiver(ProgramObject, thisValue, "Error.prototype.toString"))],
|
||||
])
|
||||
}
|
||||
return ctor
|
||||
|
||||
@@ -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 { InterpreterRuntimeError } from "./model.js"
|
||||
import { PendingThrow } 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 InterpreterRuntimeError(`Failed to parse TypeScript: ${transpiled.error}`, undefined, "ParseError")
|
||||
throw new PendingThrow("SyntaxError", `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 { type AstNode, AsyncIteratorSymbol, type GeneratorRequestKind, IteratorSymbol } from "./model.js"
|
||||
import { 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>, 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>
|
||||
(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>
|
||||
return generator.asynchronous ? promises.create(requested) : requested
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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 { type AstNode, AsyncIteratorSymbol, InterpreterRuntimeError, IteratorSymbol } from "./model.js"
|
||||
import { AsyncIteratorSymbol, IteratorSymbol, typeError } from "./model.js"
|
||||
import { generatorGlobals } from "./generators.js"
|
||||
import { promiseGlobal, type PromiseRuntime } from "./promises.js"
|
||||
import type { Runner } from "./runner.js"
|
||||
@@ -32,27 +32,24 @@ 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 = (_: unknown, __: Array<unknown>, node: AstNode) =>
|
||||
const reject = () =>
|
||||
Effect.sync(() => {
|
||||
throw new InterpreterRuntimeError("The Function constructor is not supported; write the function inline.", node)
|
||||
throw typeError("The Function constructor is not supported; write the function inline.")
|
||||
})
|
||||
return constructor<R>(runner.prototypes, runner.prototypes.Function, {
|
||||
name: "Function",
|
||||
length: 1,
|
||||
call: reject,
|
||||
construct: (args, _, node) => reject(undefined, args, node),
|
||||
construct: reject,
|
||||
})
|
||||
}
|
||||
|
||||
const symbolGlobal = <R>(runner: Runner<R>) => {
|
||||
const symbol = native<R>(runner.prototypes, {
|
||||
name: "Symbol",
|
||||
call: (_, __, node) =>
|
||||
call: () =>
|
||||
Effect.sync(() => {
|
||||
throw new InterpreterRuntimeError(
|
||||
"Symbol is not callable; only Symbol.asyncIterator and Symbol.iterator are available.",
|
||||
node,
|
||||
)
|
||||
throw typeError("Symbol is not callable; only Symbol.asyncIterator and Symbol.iterator are available.")
|
||||
}),
|
||||
callback: false,
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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
|
||||
@@ -33,32 +34,31 @@ export class GeneratorReturn {
|
||||
|
||||
export const OptionalShortCircuit: unique symbol = Symbol("codemode.optional-short-circuit")
|
||||
|
||||
export class InterpreterRuntimeError extends Error {
|
||||
readonly node?: AstNode
|
||||
/**
|
||||
* 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
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
/** The JS error class a program sees when it catches this failure. */
|
||||
readonly type: ErrorType,
|
||||
readonly 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
|
||||
}
|
||||
}
|
||||
|
||||
/** 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)
|
||||
const failure = (type: ErrorType, kind?: DiagnosticKind) => (message: string, node?: AstNode) =>
|
||||
new PendingThrow(type, message, node, kind)
|
||||
|
||||
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): InterpreterRuntimeError =>
|
||||
new InterpreterRuntimeError(
|
||||
export const unsupportedSyntax = (kind: string, node: AstNode): PendingThrow =>
|
||||
new PendingThrow(
|
||||
"SyntaxError",
|
||||
`Syntax '${kind}' is not supported. ${supportedSyntaxMessage}`,
|
||||
node,
|
||||
"UnsupportedSyntax",
|
||||
[supportedSyntaxMessage],
|
||||
"SyntaxError",
|
||||
)
|
||||
|
||||
export const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { Effect } from "effect"
|
||||
import type { Prototypes } from "./intrinsics.js"
|
||||
import { type AstNode, InterpreterRuntimeError } from "./model.js"
|
||||
import { typeError } 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 `InterpreterRuntimeError`, or an Effect. */
|
||||
export type Impl = (thisValue: unknown, args: Array<unknown>, node: AstNode) => unknown
|
||||
/** A native function body: a plain value, a thrown `PendingThrow`, or an Effect. */
|
||||
export type Impl = (thisValue: unknown, args: Array<unknown>) => unknown
|
||||
|
||||
const lift =
|
||||
<R>(impl: Impl) =>
|
||||
(thisValue: unknown, args: Array<unknown>, node: AstNode): Effect.Effect<unknown, unknown, R> =>
|
||||
(thisValue: unknown, args: Array<unknown>): Effect.Effect<unknown, unknown, R> =>
|
||||
Effect.suspend(() => {
|
||||
const result = impl(thisValue, args, node)
|
||||
const result = impl(thisValue, args)
|
||||
return Effect.isEffect(result) ? (result as Effect.Effect<unknown, unknown, R>) : Effect.succeed(result)
|
||||
})
|
||||
|
||||
@@ -44,12 +44,10 @@ export const constructor = <R>(
|
||||
}
|
||||
|
||||
/** The `call` of a constructor that JS requires to be invoked with `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)
|
||||
})
|
||||
export const requiresNew = (name: string) => (): Effect.Effect<never, unknown, never> =>
|
||||
Effect.sync(() => {
|
||||
throw typeError(`Constructor ${name} requires 'new'.`)
|
||||
})
|
||||
|
||||
/** The instance prototype for `new` via `newTarget.prototype`, falling back to the built-in's own. */
|
||||
export const prototypeFrom = (newTarget: Callable, fallback: ProgramObject): ProgramObject => {
|
||||
@@ -62,8 +60,7 @@ 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 new InterpreterRuntimeError(`${method} called on incompatible receiver ${describeValue(thisValue)}.`, node)
|
||||
throw typeError(`${method} called on incompatible receiver ${describeValue(thisValue)}.`)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { BlockStatement, Expression, Pattern } from "acorn"
|
||||
import type { Effect, Fiber } from "effect"
|
||||
import { type AstNode, AsyncIteratorSymbol, type Binding, type GeneratorRequestKind, IteratorSymbol } from "./model.js"
|
||||
import {
|
||||
AsyncIteratorSymbol,
|
||||
type Binding,
|
||||
type GeneratorRequestKind,
|
||||
IteratorSymbol,
|
||||
type PendingThrow,
|
||||
} from "./model.js"
|
||||
|
||||
/** Property attributes, as in a JS property descriptor. */
|
||||
export type Attributes = {
|
||||
@@ -42,7 +48,10 @@ 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 {}
|
||||
export class ProgramError extends ProgramObject {
|
||||
/** The interpreter failure this error materialized from, so rethrowing it keeps the diagnostic kind and location. */
|
||||
host?: PendingThrow
|
||||
}
|
||||
|
||||
export abstract class Callable extends ProgramObject {
|
||||
constructor(proto: ProgramObject, name: string, length: number) {
|
||||
@@ -67,16 +76,8 @@ export class ProgramFunction extends Callable {
|
||||
}
|
||||
}
|
||||
|
||||
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 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 NativeOptions<R> = {
|
||||
readonly name: string
|
||||
@@ -114,11 +115,7 @@ export class ProgramGenerator extends ProgramObject {
|
||||
constructor(
|
||||
proto: ProgramObject,
|
||||
readonly asynchronous: boolean,
|
||||
readonly request: (
|
||||
kind: GeneratorRequestKind,
|
||||
value: unknown,
|
||||
node: AstNode,
|
||||
) => Effect.Effect<unknown, unknown, unknown>,
|
||||
readonly request: (kind: GeneratorRequestKind, value: unknown) => Effect.Effect<unknown, unknown, unknown>,
|
||||
) {
|
||||
super(proto)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Cause, Deferred, Effect, Exit, Fiber, Scope } from "effect"
|
||||
import type { Diagnostic } from "../codemode.js"
|
||||
import { type AstNode, InterpreterRuntimeError, ProgramThrow } from "./model.js"
|
||||
import { ProgramThrow, typeError } 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 { caughtErrorValue, createAggregateErrorValue, normalizeError } from "./errors.js"
|
||||
import { createAggregateErrorValue, materialize, normalizeError } from "./errors.js"
|
||||
import { typeofValue } from "./references.js"
|
||||
import { applyCollectionCallback, isSupportedCallback, type Runner } from "./runner.js"
|
||||
|
||||
@@ -105,16 +105,14 @@ 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.fail(selfResolutionError(node))
|
||||
if (own?.promise !== undefined && value === own.promise) {
|
||||
return Effect.die(typeError("Chaining cycle detected: a promise cannot resolve with itself."))
|
||||
}
|
||||
if (value instanceof ProgramPromise) return runner.settlePromise(value)
|
||||
if (!(value instanceof ProgramObject)) return Effect.succeed(value)
|
||||
const then = get(value, "then")
|
||||
@@ -128,12 +126,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], node))
|
||||
const executed = yield* Effect.exit(runner.invokeCallable(then, value, [resolve, reject]))
|
||||
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), node, own)
|
||||
return yield* resolvePromiseValue(runner, yield* Deferred.await(deferred), own)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -141,10 +139,9 @@ 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, node, self))
|
||||
return promises.createWithSelf((self) => resolvePromiseValue(runner, value, self))
|
||||
}
|
||||
|
||||
const promiseStatics = ["all", "allSettled", "race", "any", "resolve", "reject"] as const
|
||||
@@ -154,10 +151,9 @@ 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], node)
|
||||
return resolvePromise(runner, promises, args[0])
|
||||
}
|
||||
if (name === "reject") {
|
||||
return promises.create(Effect.fail(new ProgramThrow(args[0])))
|
||||
@@ -165,15 +161,13 @@ const invokePromiseMethod = <R>(
|
||||
|
||||
return promises.create(
|
||||
Effect.gen(function* () {
|
||||
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 cursor = yield* runner.syncIterator(args[0])
|
||||
if (cursor === undefined) throw typeError(`Promise.${name} expects an array or other synchronous iterable.`)
|
||||
const items: Array<ProgramPromise> = []
|
||||
while (true) {
|
||||
const step = yield* cursor.next
|
||||
if (step.done) break
|
||||
const item = yield* resolvePromise(runner, promises, step.value, node)
|
||||
const item = yield* resolvePromise(runner, promises, step.value)
|
||||
promises.markObserved(item)
|
||||
items.push(item)
|
||||
}
|
||||
@@ -201,7 +195,7 @@ const invokePromiseMethod = <R>(
|
||||
outcomes.push(
|
||||
record(runner.prototypes.Object, {
|
||||
status: "rejected",
|
||||
reason: caughtErrorValue(runner, Cause.squash(exit.cause)),
|
||||
reason: materialize(runner, Cause.squash(exit.cause)),
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -210,10 +204,7 @@ const invokePromiseMethod = <R>(
|
||||
}
|
||||
if (name === "race") {
|
||||
if (items.length === 0) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"Promise.race([]) would never settle; provide at least one promise or value.",
|
||||
node,
|
||||
)
|
||||
throw typeError("Promise.race([]) would never settle; provide at least one promise or value.")
|
||||
}
|
||||
return yield* settleAfterTurn(Effect.flatten(Effect.raceAll(items.map((item) => promises.await(item)))))
|
||||
}
|
||||
@@ -221,7 +212,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(caughtErrorValue(runner, Cause.squash(exit.cause)))
|
||||
return Effect.succeed(materialize(runner, Cause.squash(exit.cause)))
|
||||
}),
|
||||
)
|
||||
return yield* settleAfterTurn(
|
||||
@@ -244,41 +235,36 @@ 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, node)
|
||||
const promise = receiver(ProgramPromise, thisValue, method)
|
||||
promises.markObserved(promise)
|
||||
if (name === "finally") {
|
||||
return chainFinally(runner, promises, promise, reactionHandler(args[0], method, node), method, node)
|
||||
return chainFinally(runner, promises, promise, reactionHandler(args[0], method), 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 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 constructPromise = <R>(
|
||||
runner: Runner<R>,
|
||||
promises: PromiseRuntime<R>,
|
||||
executor: unknown,
|
||||
node: AstNode,
|
||||
): Effect.Effect<ProgramPromise, unknown, R> => {
|
||||
if (!(executor instanceof ProgramFunction)) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"new Promise(...) expects an executor function (e.g. new Promise((resolve, reject) => { ... })).",
|
||||
node,
|
||||
)
|
||||
throw typeError("new Promise(...) expects an executor function (e.g. new Promise((resolve, reject) => { ... })).")
|
||||
}
|
||||
return Effect.gen(function* () {
|
||||
const deferred = Deferred.makeUnsafe<unknown, unknown>()
|
||||
const promise = yield* promises.createWithSelf((self) =>
|
||||
Effect.flatMap(Deferred.await(deferred), (value) => resolvePromiseValue(runner, value, node, self)),
|
||||
Effect.flatMap(Deferred.await(deferred), (value) => resolvePromiseValue(runner, value, 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], node))
|
||||
const executed = yield* Effect.exit(runner.invokeCallable(executor, undefined, [resolve, reject]))
|
||||
if (!Exit.isSuccess(executed)) {
|
||||
if (Cause.hasInterruptsOnly(executed.cause)) return yield* Effect.failCause(executed.cause)
|
||||
Deferred.doneUnsafe(deferred, Exit.fail(Cause.squash(executed.cause)))
|
||||
@@ -295,12 +281,11 @@ class PromiseAnyFulfilled {
|
||||
constructor(readonly value: unknown) {}
|
||||
}
|
||||
|
||||
const reactionHandler = (value: unknown, method: string, node: AstNode): Callable | undefined => {
|
||||
const reactionHandler = (value: unknown, method: string): Callable | undefined => {
|
||||
if (isSupportedCallback(value)) return value
|
||||
if (typeofValue(value) === "function") {
|
||||
throw new InterpreterRuntimeError(
|
||||
throw typeError(
|
||||
`${method} cannot use this callable as a handler; wrap it in an arrow function, e.g. (value) => tools.ns.tool(value).`,
|
||||
node,
|
||||
)
|
||||
}
|
||||
return undefined
|
||||
@@ -325,16 +310,15 @@ 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 : caughtErrorValue(runner, Cause.squash(exit.cause))
|
||||
const result = yield* applyCollectionCallback(runner, handler, method, node)([input])
|
||||
return yield* resolvePromiseValue(runner, result, node, self)
|
||||
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)
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -345,16 +329,15 @@ 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, node)([])
|
||||
const result = yield* applyCollectionCallback(runner, cleanup, method)([])
|
||||
const intermediate = yield* promises.create(
|
||||
Effect.gen(function* () {
|
||||
yield* runner.settlePromise(yield* resolvePromise(runner, promises, result, node))
|
||||
yield* runner.settlePromise(yield* resolvePromise(runner, promises, result))
|
||||
return yield* exit
|
||||
}),
|
||||
)
|
||||
@@ -371,7 +354,7 @@ export const promiseGlobal = <R>(runner: Runner<R>, promises: PromiseRuntime<R>)
|
||||
name: "Promise",
|
||||
length: 1,
|
||||
call: requiresNew("Promise"),
|
||||
construct: (args, _, node) => constructPromise(runner, promises, args[0], node),
|
||||
construct: (args) => constructPromise(runner, promises, args[0]),
|
||||
})
|
||||
// Combinators are not callbacks: `[p].map(Promise.resolve)` must ask for an arrow function.
|
||||
for (const name of promiseStatics) {
|
||||
@@ -381,16 +364,16 @@ export const promiseGlobal = <R>(runner: Runner<R>, promises: PromiseRuntime<R>)
|
||||
native<R>(protos, {
|
||||
name,
|
||||
length: 1,
|
||||
call: (_, args, node) => invokePromiseMethod(runner, promises, name, args, node),
|
||||
call: (_, args) => invokePromiseMethod(runner, promises, name, args),
|
||||
callback: false,
|
||||
}),
|
||||
hidden,
|
||||
)
|
||||
}
|
||||
methods(protos, proto, [
|
||||
["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)],
|
||||
["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)],
|
||||
])
|
||||
return promise
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ToolReference } from "../tool-runtime.js"
|
||||
import { type AstNode, InterpreterRuntimeError } from "./model.js"
|
||||
import { invalidData } from "./model.js"
|
||||
import {
|
||||
Callable,
|
||||
getOwn,
|
||||
@@ -66,11 +66,10 @@ 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 new InterpreterRuntimeError(`${label} contains a circular value.`, node, "InvalidDataValue")
|
||||
throw invalidData(`${label} contains a circular value.`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Effect, Exit } from "effect"
|
||||
import { coerceToNumber, coerceToString } from "../stdlib/value.js"
|
||||
import type { Prototypes } from "./intrinsics.js"
|
||||
import { type AstNode, InterpreterRuntimeError } from "./model.js"
|
||||
import { typeError } from "./model.js"
|
||||
import { Callable, get, NativeFunction, ProgramDate, ProgramObject, ProgramPromise } from "./objects.js"
|
||||
import { typeofValue } from "./references.js"
|
||||
|
||||
@@ -16,10 +16,9 @@ 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, node: AstNode) => Effect.Effect<IteratorCursor<R> | undefined, unknown, R>
|
||||
readonly syncIterator: (value: unknown) => Effect.Effect<IteratorCursor<R> | undefined, unknown, R>
|
||||
readonly prototypes: Prototypes
|
||||
}
|
||||
|
||||
@@ -41,7 +40,6 @@ 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)
|
||||
@@ -50,18 +48,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, [], node)
|
||||
const result = yield* runner.invokeCallable(callable, value, [])
|
||||
if (result === null || (typeof result !== "object" && typeof result !== "function")) return result
|
||||
}
|
||||
throw new InterpreterRuntimeError("Cannot convert object to primitive value.", node)
|
||||
throw typeError("Cannot convert object to primitive value.")
|
||||
})
|
||||
}
|
||||
|
||||
export const toPrimitiveString = <R>(runner: Runner<R>, value: unknown, node: AstNode) =>
|
||||
Effect.map(toPrimitive(runner, value, "string", node), coerceToString)
|
||||
export const toPrimitiveString = <R>(runner: Runner<R>, value: unknown) =>
|
||||
Effect.map(toPrimitive(runner, value, "string"), coerceToString)
|
||||
|
||||
export const toPrimitiveNumber = <R>(runner: Runner<R>, value: unknown, node: AstNode) =>
|
||||
Effect.map(toPrimitive(runner, value, "number", node), coerceToNumber)
|
||||
export const toPrimitiveNumber = <R>(runner: Runner<R>, value: unknown) =>
|
||||
Effect.map(toPrimitive(runner, value, "number"), coerceToNumber)
|
||||
|
||||
// The single acceptance list for callbacks: collections, sort, string replacers,
|
||||
// Array.from mappers, and promise reactions all admit exactly these callables.
|
||||
@@ -74,16 +72,14 @@ 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 new InterpreterRuntimeError(
|
||||
throw typeError(
|
||||
`${name} cannot use this callable as a callback; wrap it in an arrow function, e.g. (value) => tools.ns.tool(value).`,
|
||||
node,
|
||||
)
|
||||
}
|
||||
throw new InterpreterRuntimeError(`${name} expects a function callback.`, node)
|
||||
throw typeError(`${name} expects a function callback.`)
|
||||
}
|
||||
return (callbackArgs) => runner.invokeCallable(callback, undefined, callbackArgs, node)
|
||||
return (callbackArgs) => runner.invokeCallable(callback, undefined, callbackArgs)
|
||||
}
|
||||
|
||||
@@ -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 { caughtErrorValue } from "./errors.js"
|
||||
import { locate, materialize } 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 new InterpreterRuntimeError("The right-hand side of 'instanceof' is not callable.", node)
|
||||
throw typeError("The right-hand side of 'instanceof' is not callable.", node)
|
||||
}
|
||||
const prototype = get(rhs, "prototype")
|
||||
if (!(prototype instanceof ProgramObject)) {
|
||||
throw new InterpreterRuntimeError("The right-hand side of 'instanceof' has no 'prototype' object.", node)
|
||||
throw typeError("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 new InterpreterRuntimeError(`${statement} supports one declared binding.`, left)
|
||||
throw typeError(`${statement} supports one declared binding.`, left)
|
||||
}
|
||||
const kind = left.kind
|
||||
return {
|
||||
@@ -246,8 +246,6 @@ 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>
|
||||
@@ -266,9 +264,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, node) => this.root.invokeCallable(callable, thisValue, args, node),
|
||||
invokeCallable: (callable, thisValue, args) => this.root.invokeCallable(callable, thisValue, args),
|
||||
settlePromise: (promise) => this.root.settlePromise(promise),
|
||||
syncIterator: (value, node) => this.root.syncIterator(value, node),
|
||||
syncIterator: (value) => this.root.syncIterator(value),
|
||||
prototypes,
|
||||
}
|
||||
for (const [name, value] of [...globals(this), ...extraGlobals(this)]) {
|
||||
@@ -313,12 +311,12 @@ class Frame<R> {
|
||||
}
|
||||
|
||||
if (result.kind === "break" || result.kind === "continue") {
|
||||
throw new InterpreterRuntimeError(`Unexpected '${result.kind}' outside of a loop.`, statement)
|
||||
throw typeError(`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, program)
|
||||
value = yield* resolvePromiseValue(self.runtime.runner, value)
|
||||
return value
|
||||
}).pipe(Effect.ensuring(Effect.sync(() => self.scopes.pop())))
|
||||
}
|
||||
@@ -483,7 +481,7 @@ class Frame<R> {
|
||||
return Effect.gen(function* () {
|
||||
const discriminant = yield* self.evaluateExpression(node.discriminant)
|
||||
if (containsOpaqueReference(discriminant)) {
|
||||
throw new InterpreterRuntimeError("Switch discriminants must be data values.", node, "InvalidDataValue")
|
||||
throw invalidData("Switch discriminants must be data values.", node)
|
||||
}
|
||||
self.scopes.push()
|
||||
return yield* Effect.gen(function* () {
|
||||
@@ -501,7 +499,7 @@ class Frame<R> {
|
||||
}
|
||||
const candidate = yield* self.evaluateExpression(test)
|
||||
if (containsOpaqueReference(candidate)) {
|
||||
throw new InterpreterRuntimeError("Switch case values must be data values.", test, "InvalidDataValue")
|
||||
throw invalidData("Switch case values must be data values.", test)
|
||||
}
|
||||
if (candidate === discriminant) {
|
||||
selected = index
|
||||
@@ -624,7 +622,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 new InterpreterRuntimeError(
|
||||
throw invalidData(
|
||||
`${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, or URLSearchParams, or custom iterator value.`,
|
||||
node,
|
||||
)
|
||||
@@ -637,7 +635,7 @@ class Frame<R> {
|
||||
: (cursor?.close ?? Effect.void)
|
||||
|
||||
if (left.type === "RestElement" || left.type === "AssignmentPattern") {
|
||||
throw new InterpreterRuntimeError("Unsupported for...of binding.", left)
|
||||
throw typeError("Unsupported for...of binding.", left)
|
||||
}
|
||||
const assignment = left.type === "VariableDeclaration" ? undefined : left
|
||||
|
||||
@@ -664,7 +662,7 @@ class Frame<R> {
|
||||
while (true) {
|
||||
const current = iterator
|
||||
? yield* self.nextIteratorResult(iterator, node, awaiting)
|
||||
: yield* cursor?.next ?? Effect.fail(new InterpreterRuntimeError("Iterator is unavailable.", node))
|
||||
: yield* cursor?.next ?? Effect.die(typeError("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))
|
||||
@@ -690,8 +688,8 @@ class Frame<R> {
|
||||
)
|
||||
}
|
||||
|
||||
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) =>
|
||||
private awaitValue(value: unknown): Effect.Effect<unknown, unknown, R> {
|
||||
return Effect.flatMap(resolvePromise(this.runtime.runner, this.runtime.promises, value), (promise) =>
|
||||
this.settlePromise(promise),
|
||||
)
|
||||
}
|
||||
@@ -699,7 +697,7 @@ class Frame<R> {
|
||||
private awaitAsyncFromSyncValue(
|
||||
iterator: CustomIterator,
|
||||
value: unknown,
|
||||
node: AstNode,
|
||||
node: AstNode | undefined,
|
||||
closeOnRejection: boolean,
|
||||
): Effect.Effect<unknown, unknown, R> {
|
||||
const self = this
|
||||
@@ -713,7 +711,7 @@ class Frame<R> {
|
||||
})
|
||||
}
|
||||
|
||||
syncIterator(value: unknown, node: AstNode) {
|
||||
syncIterator(value: unknown, node?: AstNode) {
|
||||
const iterator =
|
||||
value instanceof ProgramArray
|
||||
? value.items[Symbol.iterator]()
|
||||
@@ -750,7 +748,7 @@ class Frame<R> {
|
||||
)
|
||||
}
|
||||
|
||||
private customIterator(value: unknown, node: AstNode, allowAsync = true) {
|
||||
private customIterator(value: unknown, node: AstNode | undefined, allowAsync = true) {
|
||||
if (!(value instanceof ProgramObject)) return Effect.undefined
|
||||
const asyncMethod = allowAsync ? get(value, AsyncIteratorSymbol) : undefined
|
||||
const method = asyncMethod ?? get(value, IteratorSymbol)
|
||||
@@ -769,7 +767,7 @@ class Frame<R> {
|
||||
)
|
||||
}
|
||||
|
||||
private nextIteratorResult(iterator: CustomIterator, node: AstNode, awaiting: boolean) {
|
||||
private nextIteratorResult(iterator: CustomIterator, node: AstNode | undefined, awaiting: boolean) {
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
if (iterator.asynchronous) {
|
||||
@@ -805,7 +803,11 @@ class Frame<R> {
|
||||
})
|
||||
}
|
||||
|
||||
private closeIterator(iterator: CustomIterator, node: AstNode, awaiting = true): Effect.Effect<void, unknown, R> {
|
||||
private closeIterator(
|
||||
iterator: CustomIterator,
|
||||
node: AstNode | undefined,
|
||||
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
|
||||
@@ -836,14 +838,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 new InterpreterRuntimeError(`${context} must be an object.`, node)
|
||||
throw typeError(`${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 new InterpreterRuntimeError(`${context} must be a function.`, node)
|
||||
throw typeError(`${context} must be a function.`, node)
|
||||
}
|
||||
|
||||
// for...in over null/undefined iterates nothing, like JS.
|
||||
@@ -869,7 +871,7 @@ class Frame<R> {
|
||||
const keys = self.enumerableKeys(right, node.right)
|
||||
|
||||
if (left.type !== "Identifier" && left.type !== "VariableDeclaration") {
|
||||
throw new InterpreterRuntimeError("Unsupported for...in binding.", left)
|
||||
throw typeError("Unsupported for...in binding.", left)
|
||||
}
|
||||
const assignmentName = left.type === "Identifier" ? left.name : undefined
|
||||
|
||||
@@ -955,7 +957,7 @@ class Frame<R> {
|
||||
return Effect.failCause(cause)
|
||||
}
|
||||
|
||||
const caught = caughtErrorValue(self.runtime.runner, Cause.squash(cause))
|
||||
const caught = materialize(self.runtime.runner, Cause.squash(cause))
|
||||
const parameter = handler.param
|
||||
self.scopes.push()
|
||||
return Effect.gen(function* () {
|
||||
@@ -991,7 +993,7 @@ class Frame<R> {
|
||||
return Effect.gen(function* () {
|
||||
for (const declaration of node.declarations) {
|
||||
if (declaration.type !== "VariableDeclarator") {
|
||||
throw new InterpreterRuntimeError("Unsupported variable declaration shape.", declaration)
|
||||
throw typeError("Unsupported variable declaration shape.", declaration)
|
||||
}
|
||||
|
||||
const init = declaration.init
|
||||
@@ -1033,10 +1035,9 @@ class Frame<R> {
|
||||
|
||||
if (pattern.type === "ObjectPattern") {
|
||||
if (!(value instanceof ProgramObject)) {
|
||||
throw new InterpreterRuntimeError(
|
||||
throw typeError(
|
||||
`Object destructuring requires a data object or array value, received ${describeValue(value)}.`,
|
||||
pattern,
|
||||
"InvalidDataValue",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1051,7 +1052,13 @@ class Frame<R> {
|
||||
|
||||
const key = yield* self.destructuringPropertyKey(property)
|
||||
consumed.add(typeof key === "symbol" ? key : String(key))
|
||||
yield* self.declarePattern(property.value, get(value, key), mutable, property, initialize)
|
||||
yield* self.declarePattern(
|
||||
property.value,
|
||||
self.readProperty(value, key, property),
|
||||
mutable,
|
||||
property,
|
||||
initialize,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1062,7 +1069,7 @@ class Frame<R> {
|
||||
)
|
||||
}
|
||||
|
||||
throw new InterpreterRuntimeError(`Unsupported binding pattern '${pattern.type}'.`, pattern)
|
||||
throw typeError(`Unsupported binding pattern '${pattern.type}'.`, pattern)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1087,10 +1094,9 @@ class Frame<R> {
|
||||
|
||||
if (pattern.type === "ObjectPattern") {
|
||||
if (!(value instanceof ProgramObject)) {
|
||||
throw new InterpreterRuntimeError(
|
||||
throw invalidData(
|
||||
`Object destructuring requires a data object or array value, received ${describeValue(value)}.`,
|
||||
pattern,
|
||||
"InvalidDataValue",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1104,7 +1110,7 @@ class Frame<R> {
|
||||
}
|
||||
const key = yield* self.destructuringPropertyKey(property)
|
||||
consumed.add(typeof key === "symbol" ? key : String(key))
|
||||
yield* self.assignPattern(property.value, get(value, key), property)
|
||||
yield* self.assignPattern(property.value, self.readProperty(value, key, property), property)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1115,7 +1121,7 @@ class Frame<R> {
|
||||
)
|
||||
}
|
||||
|
||||
throw new InterpreterRuntimeError(`Unsupported assignment pattern '${pattern.type}'.`, node)
|
||||
throw typeError(`Unsupported assignment pattern '${pattern.type}'.`, node)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1134,7 +1140,7 @@ class Frame<R> {
|
||||
return Effect.gen(function* () {
|
||||
const cursor = yield* self.syncIterator(value, pattern)
|
||||
if (cursor === undefined) {
|
||||
throw new InterpreterRuntimeError("Array destructuring requires a supported iterable value.", pattern)
|
||||
throw typeError("Array destructuring requires a supported iterable value.", pattern)
|
||||
}
|
||||
let done = false
|
||||
for (const element of pattern.elements) {
|
||||
@@ -1171,7 +1177,7 @@ class Frame<R> {
|
||||
|
||||
private destructuringPropertyKey(property: Property | AssignmentProperty): Effect.Effect<PropertyKey, unknown, R> {
|
||||
if (property.type !== "Property" || property.kind !== "init") {
|
||||
throw new InterpreterRuntimeError("Unsupported object destructuring property.", property)
|
||||
throw typeError("Unsupported object destructuring property.", property)
|
||||
}
|
||||
const keyNode = property.key
|
||||
if (property.computed) {
|
||||
@@ -1186,8 +1192,7 @@ 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], node))
|
||||
if (regex) return Effect.sync(() => constructRegExp(this.runtime.prototypes, [regex.pattern, regex.flags]))
|
||||
return Effect.sync(() => toProgram(this.runtime.prototypes, node.value, "Literal"))
|
||||
}
|
||||
case "Identifier":
|
||||
@@ -1233,7 +1238,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, node))
|
||||
return Effect.flatMap(this.evaluateExpression(node.argument), (value) => this.awaitValue(value))
|
||||
}
|
||||
case "YieldExpression":
|
||||
return this.evaluateYieldExpression(node)
|
||||
@@ -1261,10 +1266,12 @@ class Frame<R> {
|
||||
: callee instanceof NativeFunction
|
||||
? `new ${name}(...) is not supported; call ${name}(...) without new instead.`
|
||||
: `${name} is not a constructor.`
|
||||
throw new InterpreterRuntimeError(message, node)
|
||||
throw typeError(message, node)
|
||||
}
|
||||
const args = yield* self.evaluateCallArguments(node.arguments)
|
||||
return yield* construct(args, callee as NativeFunction<R>, node)
|
||||
return yield* Effect.catchDefect(construct(args, callee as NativeFunction<R>), (defect) =>
|
||||
Effect.die(locate(defect, node)),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1292,7 +1299,7 @@ class Frame<R> {
|
||||
return has(rhs, lhs !== null && typeof lhs === "object" ? coerceToString(lhs) : (lhs as PropertyKey))
|
||||
}
|
||||
if (containsOpaqueReference(lhs) || containsOpaqueReference(rhs)) {
|
||||
throw new InterpreterRuntimeError("Binary operators require data values.", node, "InvalidDataValue")
|
||||
throw invalidData("Binary operators require data values.", node)
|
||||
}
|
||||
// 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.
|
||||
@@ -1344,11 +1351,11 @@ class Frame<R> {
|
||||
return (l as number) >>> (r as number)
|
||||
case "in":
|
||||
if (!(rhs instanceof ProgramObject)) {
|
||||
throw new InterpreterRuntimeError("The 'in' operator requires a data object on the right-hand side.", node)
|
||||
throw typeError("The 'in' operator requires a data object on the right-hand side.", node)
|
||||
}
|
||||
return has(rhs, coerceOperand(lhs) as PropertyKey)
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`Unsupported binary operator '${operator}'.`, node)
|
||||
throw typeError(`Unsupported binary operator '${operator}'.`, node)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1359,7 +1366,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 new InterpreterRuntimeError(`Unsupported logical operator '${operator}'.`, node)
|
||||
throw typeError(`Unsupported logical operator '${operator}'.`, node)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1376,7 +1383,7 @@ class Frame<R> {
|
||||
if (operator === "!") return !value
|
||||
if (operator === "void") return undefined
|
||||
if (containsOpaqueReference(value)) {
|
||||
throw new InterpreterRuntimeError("Unary operators require data values.", node, "InvalidDataValue")
|
||||
throw invalidData("Unary operators require data values.", node)
|
||||
}
|
||||
const operand =
|
||||
value instanceof ProgramDate
|
||||
@@ -1396,7 +1403,7 @@ class Frame<R> {
|
||||
result = ~(operand as number)
|
||||
break
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`Unsupported unary operator '${operator}'.`, node)
|
||||
throw typeError(`Unsupported unary operator '${operator}'.`, node)
|
||||
}
|
||||
return toProgram(this.runtime.prototypes, result, "Unary expression result")
|
||||
})
|
||||
@@ -1443,7 +1450,7 @@ class Frame<R> {
|
||||
}),
|
||||
)
|
||||
}
|
||||
throw new InterpreterRuntimeError("Assignment target must be an Identifier or MemberExpression.", left)
|
||||
throw typeError("Assignment target must be an Identifier or MemberExpression.", left)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1475,7 +1482,7 @@ class Frame<R> {
|
||||
: Effect.succeed({ write: false, next: current, result: current }),
|
||||
)
|
||||
}
|
||||
throw new InterpreterRuntimeError("Assignment target must be an Identifier or MemberExpression.", left)
|
||||
throw typeError("Assignment target must be an Identifier or MemberExpression.", left)
|
||||
}
|
||||
|
||||
private evaluateUpdateExpression(node: UpdateExpression): Effect.Effect<unknown, unknown, R> {
|
||||
@@ -1486,14 +1493,14 @@ class Frame<R> {
|
||||
const increment = operator === "++" ? 1 : operator === "--" ? -1 : undefined
|
||||
|
||||
if (increment === undefined) {
|
||||
throw new InterpreterRuntimeError(`Unsupported update operator '${operator}'.`, node)
|
||||
throw typeError(`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 new InterpreterRuntimeError(`'${operator}' requires a data value.`, argument, "InvalidDataValue")
|
||||
throw invalidData(`'${operator}' requires a data value.`, argument)
|
||||
}
|
||||
return coerceToNumber(current)
|
||||
}
|
||||
@@ -1516,7 +1523,7 @@ class Frame<R> {
|
||||
})
|
||||
}
|
||||
|
||||
throw new InterpreterRuntimeError("Update target must be an Identifier or MemberExpression.", argument)
|
||||
throw typeError("Update target must be an Identifier or MemberExpression.", argument)
|
||||
}
|
||||
|
||||
// EvaluateCall: a member callee supplies its base object as `this`; anything else calls with undefined.
|
||||
@@ -1552,20 +1559,24 @@ 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 new InterpreterRuntimeError("The tools root is not callable.", callee ?? node)
|
||||
throw typeError("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* (callable as NativeFunction<R>).call(thisValue, args, node)
|
||||
throw new InterpreterRuntimeError(`${calleeDescription(callee)} is not a function.`, callee ?? node)
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1579,8 +1590,7 @@ 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 new InterpreterRuntimeError("Spread arguments require a synchronous iterable.", argNode)
|
||||
if (cursor === undefined) throw typeError("Spread arguments require a synchronous iterable.", argNode)
|
||||
while (true) {
|
||||
const step = yield* cursor.next
|
||||
if (step.done) break
|
||||
@@ -1631,7 +1641,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, fn.body, self)),
|
||||
Effect.flatMap(run, (value) => resolvePromiseValue(invocation.runtime.runner, value, self)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1645,11 +1655,9 @@ 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, node: AstNode) => {
|
||||
const request = (kind: GeneratorRequestKind, value: unknown) => {
|
||||
const request = { kind, value, response: Deferred.makeUnsafe<unknown, unknown>() }
|
||||
if (!asynchronous && state.active) {
|
||||
return Effect.fail(new InterpreterRuntimeError("Generator is already running.", node))
|
||||
}
|
||||
if (!asynchronous && state.active) return Effect.die(typeError("Generator is already running."))
|
||||
if (asynchronous && (state.completed || (!state.started && kind !== "next"))) {
|
||||
state.started = true
|
||||
state.completed = true
|
||||
@@ -1769,7 +1777,7 @@ class Frame<R> {
|
||||
const argument = node.argument
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
if (!self.generatorState) throw new InterpreterRuntimeError("yield is only valid inside a generator.", node)
|
||||
if (!self.generatorState) throw typeError("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)
|
||||
@@ -1782,7 +1790,7 @@ class Frame<R> {
|
||||
|
||||
private suspendGenerator(value: unknown, node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
const state = this.generatorState
|
||||
if (!state?.active) throw new InterpreterRuntimeError("Generator has no active request.", node)
|
||||
if (!state?.active) throw typeError("Generator has no active request.", node)
|
||||
Deferred.doneUnsafe(
|
||||
state.active.response,
|
||||
Exit.succeed(record(this.runtime.prototypes.Object, { value, done: false })),
|
||||
@@ -1809,7 +1817,7 @@ class Frame<R> {
|
||||
value instanceof ProgramURLSearchParams
|
||||
) {
|
||||
const cursor = yield* self.syncIterator(value, node)
|
||||
if (!cursor) throw new InterpreterRuntimeError("Built-in iterator is unavailable.", node)
|
||||
if (!cursor) throw typeError("Built-in iterator is unavailable.", node)
|
||||
while (true) {
|
||||
const step = yield* cursor.next
|
||||
if (step.done) return undefined
|
||||
@@ -1824,14 +1832,14 @@ class Frame<R> {
|
||||
}
|
||||
if (error instanceof ProgramThrow) {
|
||||
yield* cursor.close
|
||||
throw new InterpreterRuntimeError("The delegated iterator does not provide a throw() method.", node)
|
||||
throw typeError("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 new InterpreterRuntimeError("yield* requires a compatible iterable value.", node)
|
||||
if (!iterator) throw typeError("yield* requires a compatible iterable value.", node)
|
||||
let kind: GeneratorRequestKind = "next"
|
||||
let input: unknown = undefined
|
||||
while (true) {
|
||||
@@ -1839,7 +1847,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 new InterpreterRuntimeError("The delegated iterator does not provide a throw() method.", node)
|
||||
throw typeError("The delegated iterator does not provide a throw() method.", node)
|
||||
}
|
||||
const called = yield* self.invokeCallable(
|
||||
self.requireIteratorMethod(method, `Iterator ${kind}`, node),
|
||||
@@ -1891,7 +1899,7 @@ class Frame<R> {
|
||||
}
|
||||
|
||||
if (property.kind !== "init") {
|
||||
throw new InterpreterRuntimeError("Only init object properties are supported.", property)
|
||||
throw typeError("Only init object properties are supported.", property)
|
||||
}
|
||||
|
||||
const keyNode = property.key
|
||||
@@ -1905,7 +1913,7 @@ class Frame<R> {
|
||||
} else if (keyNode.type === "Literal") {
|
||||
key = self.toPropertyKey(keyNode.value, keyNode)
|
||||
} else {
|
||||
throw new InterpreterRuntimeError("Unsupported object property key shape.", keyNode)
|
||||
throw typeError("Unsupported object property key shape.", keyNode)
|
||||
}
|
||||
|
||||
const name =
|
||||
@@ -1935,8 +1943,7 @@ 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 new InterpreterRuntimeError("Array spread requires a synchronous iterable.", element)
|
||||
if (cursor === undefined) throw typeError("Array spread requires a synchronous iterable.", element)
|
||||
while (true) {
|
||||
const step = yield* cursor.next
|
||||
if (step.done) break
|
||||
@@ -1962,7 +1969,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 new InterpreterRuntimeError("Invalid template literal quasi.", quasi)
|
||||
throw typeError("Invalid template literal quasi.", quasi)
|
||||
}
|
||||
output += quasi.value.cooked
|
||||
|
||||
@@ -1984,7 +1991,7 @@ class Frame<R> {
|
||||
|
||||
private applyCompoundAssignment(operator: string, current: unknown, incoming: unknown, node: AstNode): unknown {
|
||||
if (!compoundOperators.has(operator)) {
|
||||
throw new InterpreterRuntimeError(`Unsupported assignment operator '${operator}'.`, node)
|
||||
throw typeError(`Unsupported assignment operator '${operator}'.`, node)
|
||||
}
|
||||
return this.applyBinaryOperator(operator.slice(0, -1), current, incoming, node)
|
||||
}
|
||||
@@ -2010,7 +2017,7 @@ class Frame<R> {
|
||||
|
||||
if (objectValue instanceof ToolReference) {
|
||||
if (typeof key !== "string") {
|
||||
throw new InterpreterRuntimeError("Tool paths must use string property names.", propertyNode)
|
||||
throw typeError("Tool paths must use string property names.", propertyNode)
|
||||
}
|
||||
return new ToolReference([...objectValue.path, key])
|
||||
}
|
||||
@@ -2029,26 +2036,27 @@ class Frame<R> {
|
||||
if (typeof objectValue === "boolean") return { target: protos.Boolean, key, receiver: objectValue }
|
||||
|
||||
if (objectValue === null || objectValue === undefined) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`Cannot read properties of ${objectValue} (reading '${String(key)}').`,
|
||||
objectNode,
|
||||
)
|
||||
throw typeError(`Cannot read properties of ${objectValue} (reading '${String(key)}').`, objectNode)
|
||||
}
|
||||
throw new InterpreterRuntimeError("Cannot access a property on a non-object value.", objectNode)
|
||||
throw typeError("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 new InterpreterRuntimeError(
|
||||
throw invalidData(
|
||||
"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(reference.target, reference.key, reference.receiver)
|
||||
return get(target, key, receiver)
|
||||
} catch (error) {
|
||||
throw locate(error, node)
|
||||
}
|
||||
@@ -2070,15 +2078,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 new InterpreterRuntimeError("Only data fields may be deleted.", argument)
|
||||
throw typeError("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 new InterpreterRuntimeError("Only data fields may be deleted.", target, "InvalidDataValue")
|
||||
throw invalidData("Only data fields may be deleted.", target)
|
||||
}
|
||||
if (remove(reference.target, reference.key)) return true
|
||||
throw new InterpreterRuntimeError(`Cannot delete property '${String(reference.key)}'.`, target)
|
||||
throw typeError(`Cannot delete property '${String(reference.key)}'.`, target)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2091,10 +2099,10 @@ class Frame<R> {
|
||||
return Effect.gen(function* () {
|
||||
const reference = yield* self.getMemberReference(node)
|
||||
if (reference === OptionalShortCircuit || reference instanceof ToolReference || "value" in reference) {
|
||||
throw new InterpreterRuntimeError("Only data fields may be assigned.", node)
|
||||
throw typeError("Only data fields may be assigned.", node)
|
||||
}
|
||||
if (reference.receiver !== reference.target) {
|
||||
throw new InterpreterRuntimeError(
|
||||
throw typeError(
|
||||
`Cannot create property '${String(reference.key)}' on ${typeof reference.receiver} '${String(reference.receiver)}'.`,
|
||||
node,
|
||||
)
|
||||
@@ -2107,14 +2115,13 @@ 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)
|
||||
@@ -2122,7 +2129,7 @@ class Frame<R> {
|
||||
})()
|
||||
if (written) return
|
||||
if (target instanceof ProgramArray && key === "length") throw rangeError("Invalid array length", node)
|
||||
throw new InterpreterRuntimeError(`Cannot assign to read only property '${String(key)}'.`, node)
|
||||
throw typeError(`Cannot assign to read only property '${String(key)}'.`, node)
|
||||
}
|
||||
|
||||
private toPropertyKey(value: unknown, node: AstNode): PropertyKey {
|
||||
@@ -2131,9 +2138,6 @@ class Frame<R> {
|
||||
}
|
||||
if (value === AsyncIteratorSymbol || value === IteratorSymbol) return value
|
||||
|
||||
throw new InterpreterRuntimeError(
|
||||
"Property key must be a string or number, or Symbol.asyncIterator/Symbol.iterator.",
|
||||
node,
|
||||
)
|
||||
throw typeError("Property key must be a string or number, or Symbol.asyncIterator/Symbol.iterator.", node)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type AstNode, type Binding, InterpreterRuntimeError, referenceError } from "./model.js"
|
||||
import { type AstNode, type Binding, referenceError, typeError } 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 new InterpreterRuntimeError(`Identifier '${name}' has already been declared.`, node)
|
||||
throw typeError(`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 new InterpreterRuntimeError(`Identifier '${name}' has not been reserved for initialization.`, node)
|
||||
throw typeError(`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 new InterpreterRuntimeError(`Identifier '${name}' has already been declared.`, node)
|
||||
throw typeError(`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 new InterpreterRuntimeError(`Cannot assign to constant '${name}'.`, node)
|
||||
throw typeError(`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 new InterpreterRuntimeError("Interpreter scope stack is empty.")
|
||||
throw typeError("Interpreter scope stack is empty.")
|
||||
}
|
||||
|
||||
return scope
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Effect } from "effect"
|
||||
import { constructor, type Method, methods, prototypeFrom, receiver } from "../interpreter/native.js"
|
||||
import { type AstNode, InterpreterRuntimeError, rangeError } from "../interpreter/model.js"
|
||||
import { invalidData, rangeError, typeError } 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,35 +9,30 @@ import { coerceToNumber, coerceToString } from "./value.js"
|
||||
|
||||
const MAX_LENGTH = 4_294_967_295
|
||||
|
||||
const arrayLikeSource = (
|
||||
source: unknown,
|
||||
node: AstNode,
|
||||
): { readonly length: number; readonly source: ProgramObject } => {
|
||||
const arrayLikeSource = (source: unknown): { 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 new InterpreterRuntimeError(
|
||||
throw invalidData(
|
||||
`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>, node: AstNode): Effect.Effect<unknown, unknown, R> => {
|
||||
const arrayFrom = <R>(runner: Runner<R>, args: Array<unknown>): 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", node)
|
||||
args.length < 2 || args[1] === undefined ? undefined : applyCollectionCallback(runner, args[1], "Array.from")
|
||||
return Effect.gen(function* () {
|
||||
const cursor = yield* runner.syncIterator(source, node)
|
||||
const cursor = yield* runner.syncIterator(source)
|
||||
if (cursor === undefined) {
|
||||
if (source instanceof ProgramGenerator) {
|
||||
throw new InterpreterRuntimeError("Array.from expects a synchronous iterable or array-like value.", node)
|
||||
throw typeError("Array.from expects a synchronous iterable or array-like value.")
|
||||
}
|
||||
const arrayLike = arrayLikeSource(source, node)
|
||||
const arrayLike = arrayLikeSource(source)
|
||||
const values: Array<unknown> = []
|
||||
for (let index = 0; index < arrayLike.length; index += 1) {
|
||||
const item = get(arrayLike.source, index)
|
||||
@@ -61,12 +56,11 @@ 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, node)
|
||||
const apply = applyCollectionCallback(runner, comparator, name)
|
||||
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)
|
||||
@@ -95,32 +89,31 @@ 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, node: AstNode): ProgramArray => {
|
||||
const construct = (args: Array<unknown>, into: ProgramObject): 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.", node)
|
||||
if (!Number.isInteger(first) || first < 0 || first > MAX_LENGTH) throw rangeError("Invalid array length.")
|
||||
// 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, node) => Effect.sync(() => construct(args, proto, node)),
|
||||
construct: (args, newTarget, node) => Effect.sync(() => construct(args, prototypeFrom(newTarget, proto), node)),
|
||||
call: (_, args) => Effect.sync(() => construct(args, proto)),
|
||||
construct: (args, newTarget) => Effect.sync(() => construct(args, prototypeFrom(newTarget, proto))),
|
||||
})
|
||||
methods(protos, array, [
|
||||
["isArray", 1, (_, args) => args[0] instanceof ProgramArray],
|
||||
["of", 0, (_, args) => wrap([...args])],
|
||||
["from", 1, (_, args, node) => arrayFrom(runner, args, node)],
|
||||
["from", 1, (_, args) => arrayFrom(runner, args)],
|
||||
])
|
||||
|
||||
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 => {
|
||||
const self = (thisValue: unknown, name: string) => receiver(ProgramArray, thisValue, `Array.prototype.${name}`)
|
||||
const optNumber = (name: string, value: unknown, label: string): number | undefined => {
|
||||
if (value === undefined) return undefined
|
||||
if (typeof value !== "number") {
|
||||
throw new InterpreterRuntimeError(`Array.${name} expects ${label} to be a number.`, node)
|
||||
throw typeError(`Array.${name} expects ${label} to be a number.`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -133,14 +126,13 @@ 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, node) => {
|
||||
const target = self(thisValue, name, node)
|
||||
return body(target.items, target, applyCollectionCallback(runner, args[0], `Array.${name}`, node), args, node)
|
||||
(thisValue, args) => {
|
||||
const target = self(thisValue, name)
|
||||
return body(target.items, target, applyCollectionCallback(runner, args[0], `Array.${name}`), args)
|
||||
},
|
||||
]
|
||||
|
||||
@@ -148,10 +140,10 @@ export const arrayGlobal = <R>(runner: Runner<R>) => {
|
||||
[
|
||||
"join",
|
||||
1,
|
||||
(thisValue, args, node) => {
|
||||
const target = self(thisValue, "join", node).items
|
||||
(thisValue, args) => {
|
||||
const target = self(thisValue, "join").items
|
||||
if (args.length > 1 || (args.length === 1 && typeof args[0] !== "string")) {
|
||||
throw new InterpreterRuntimeError("Array.join expects zero arguments or one string separator.", node)
|
||||
throw typeError("Array.join expects zero arguments or one string separator.")
|
||||
}
|
||||
return target.map((item) => coerceToString(item ?? "")).join(args.length === 0 ? "," : (args[0] as string))
|
||||
},
|
||||
@@ -159,60 +151,56 @@ export const arrayGlobal = <R>(runner: Runner<R>) => {
|
||||
[
|
||||
"toString",
|
||||
0,
|
||||
(thisValue, _, node) =>
|
||||
self(thisValue, "toString", node)
|
||||
(thisValue) =>
|
||||
self(thisValue, "toString")
|
||||
.items.map((item) => coerceToString(item ?? ""))
|
||||
.join(","),
|
||||
],
|
||||
[
|
||||
"includes",
|
||||
1,
|
||||
(thisValue, args, node) => {
|
||||
const target = self(thisValue, "includes", node).items
|
||||
(thisValue, args) => {
|
||||
const target = self(thisValue, "includes").items
|
||||
if (args.length === 0 || args.length > 2) {
|
||||
throw new InterpreterRuntimeError("Array.includes expects a value and optional start index.", node)
|
||||
throw typeError("Array.includes expects a value and optional start index.")
|
||||
}
|
||||
return target.includes(args[0], optNumber("includes", args[1], "start index", node))
|
||||
return target.includes(args[0], optNumber("includes", args[1], "start index"))
|
||||
},
|
||||
],
|
||||
[
|
||||
"indexOf",
|
||||
1,
|
||||
(thisValue, args, node) =>
|
||||
self(thisValue, "indexOf", node).items.indexOf(args[0], optNumber("indexOf", args[1], "start index", node)),
|
||||
(thisValue, args) =>
|
||||
self(thisValue, "indexOf").items.indexOf(args[0], optNumber("indexOf", args[1], "start index")),
|
||||
],
|
||||
[
|
||||
"lastIndexOf",
|
||||
1,
|
||||
(thisValue, args, node) => {
|
||||
const target = self(thisValue, "lastIndexOf", node).items
|
||||
(thisValue, args) => {
|
||||
const target = self(thisValue, "lastIndexOf").items
|
||||
return args[1] === undefined
|
||||
? target.lastIndexOf(args[0])
|
||||
: target.lastIndexOf(args[0], optNumber("lastIndexOf", args[1], "start index", node))
|
||||
: target.lastIndexOf(args[0], optNumber("lastIndexOf", args[1], "start index"))
|
||||
},
|
||||
],
|
||||
[
|
||||
"at",
|
||||
1,
|
||||
(thisValue, args, node) => self(thisValue, "at", node).items.at(optNumber("at", args[0], "index", node) ?? 0),
|
||||
],
|
||||
["at", 1, (thisValue, args) => self(thisValue, "at").items.at(optNumber("at", args[0], "index") ?? 0)],
|
||||
[
|
||||
"slice",
|
||||
2,
|
||||
(thisValue, args, node) =>
|
||||
(thisValue, args) =>
|
||||
wrap(
|
||||
self(thisValue, "slice", node).items.slice(
|
||||
optNumber("slice", args[0], "start", node),
|
||||
optNumber("slice", args[1], "end", node),
|
||||
self(thisValue, "slice").items.slice(
|
||||
optNumber("slice", args[0], "start"),
|
||||
optNumber("slice", args[1], "end"),
|
||||
),
|
||||
),
|
||||
],
|
||||
[
|
||||
"concat",
|
||||
1,
|
||||
(thisValue, args, node) =>
|
||||
(thisValue, args) =>
|
||||
wrap(
|
||||
self(thisValue, "concat", node).items.concat(
|
||||
self(thisValue, "concat").items.concat(
|
||||
...args.map((item) => (item instanceof ProgramArray ? item.items : item)),
|
||||
),
|
||||
),
|
||||
@@ -220,17 +208,17 @@ export const arrayGlobal = <R>(runner: Runner<R>) => {
|
||||
[
|
||||
"flat",
|
||||
0,
|
||||
(thisValue, args, node) => {
|
||||
(thisValue, args) => {
|
||||
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", node).items, optNumber("flat", args[0], "depth", node) ?? 1))
|
||||
return wrap(flatten(self(thisValue, "flat").items, optNumber("flat", args[0], "depth") ?? 1))
|
||||
},
|
||||
],
|
||||
[
|
||||
"reverse",
|
||||
0,
|
||||
(thisValue, _, node) => {
|
||||
const target = self(thisValue, "reverse", node)
|
||||
(thisValue) => {
|
||||
const target = self(thisValue, "reverse")
|
||||
target.items.reverse()
|
||||
return target
|
||||
},
|
||||
@@ -238,13 +226,13 @@ export const arrayGlobal = <R>(runner: Runner<R>) => {
|
||||
[
|
||||
"sort",
|
||||
1,
|
||||
(thisValue, args, node) => {
|
||||
const target = self(thisValue, "sort", node)
|
||||
(thisValue, args) => {
|
||||
const target = self(thisValue, "sort")
|
||||
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", node), (sorted) => {
|
||||
return Effect.map(sortArray(runner, items, args[0], "Array.sort"), (sorted) => {
|
||||
sorted.slice(0, itemCount).forEach((item, index) => {
|
||||
items[index] = item
|
||||
})
|
||||
@@ -258,18 +246,18 @@ export const arrayGlobal = <R>(runner: Runner<R>) => {
|
||||
[
|
||||
"toSorted",
|
||||
1,
|
||||
(thisValue, args, node) =>
|
||||
Effect.map(sortArray(runner, self(thisValue, "toSorted", node).items, args[0], "Array.toSorted", node), wrap),
|
||||
(thisValue, args) =>
|
||||
Effect.map(sortArray(runner, self(thisValue, "toSorted").items, args[0], "Array.toSorted"), wrap),
|
||||
],
|
||||
["toReversed", 0, (thisValue, _, node) => wrap([...self(thisValue, "toReversed", node).items].reverse())],
|
||||
["toReversed", 0, (thisValue) => wrap([...self(thisValue, "toReversed").items].reverse())],
|
||||
[
|
||||
"with",
|
||||
2,
|
||||
(thisValue, args, node) => {
|
||||
const target = self(thisValue, "with", node).items
|
||||
const index = optNumber("with", args[0], "index", node) ?? 0
|
||||
(thisValue, args) => {
|
||||
const target = self(thisValue, "with").items
|
||||
const index = optNumber("with", args[0], "index") ?? 0
|
||||
const resolved = index < 0 ? target.length + index : index
|
||||
if (resolved < 0 || resolved >= target.length) throw rangeError("Array.with index is out of range.", node)
|
||||
if (resolved < 0 || resolved >= target.length) throw rangeError("Array.with index is out of range.")
|
||||
const copied = [...target]
|
||||
copied[resolved] = args[1]
|
||||
return wrap(copied)
|
||||
@@ -278,80 +266,80 @@ export const arrayGlobal = <R>(runner: Runner<R>) => {
|
||||
[
|
||||
"push",
|
||||
1,
|
||||
(thisValue, args, node) => {
|
||||
const target = self(thisValue, "push", node)
|
||||
(thisValue, args) => {
|
||||
const target = self(thisValue, "push")
|
||||
// Validate all insertions before mutating to avoid partial cyclic updates.
|
||||
for (const item of args) rejectCircularInsertion(target, item, "Array.push result", node)
|
||||
for (const item of args) rejectCircularInsertion(target, item, "Array.push result")
|
||||
return target.items.push(...args)
|
||||
},
|
||||
],
|
||||
[
|
||||
"unshift",
|
||||
1,
|
||||
(thisValue, args, node) => {
|
||||
const target = self(thisValue, "unshift", node)
|
||||
for (const item of args) rejectCircularInsertion(target, item, "Array.unshift result", node)
|
||||
(thisValue, args) => {
|
||||
const target = self(thisValue, "unshift")
|
||||
for (const item of args) rejectCircularInsertion(target, item, "Array.unshift result")
|
||||
return target.items.unshift(...args)
|
||||
},
|
||||
],
|
||||
["pop", 0, (thisValue, _, node) => self(thisValue, "pop", node).items.pop()],
|
||||
["shift", 0, (thisValue, _, node) => self(thisValue, "shift", node).items.shift()],
|
||||
["pop", 0, (thisValue) => self(thisValue, "pop").items.pop()],
|
||||
["shift", 0, (thisValue) => self(thisValue, "shift").items.shift()],
|
||||
[
|
||||
"splice",
|
||||
2,
|
||||
(thisValue, args, node) => {
|
||||
const target = self(thisValue, "splice", node)
|
||||
(thisValue, args) => {
|
||||
const target = self(thisValue, "splice")
|
||||
if (args.length === 0) return wrap(target.items.splice(0, 0))
|
||||
const start = optNumber("splice", args[0], "start", node) ?? 0
|
||||
const start = optNumber("splice", args[0], "start") ?? 0
|
||||
if (args.length === 1) return wrap(target.items.splice(start))
|
||||
const deleteCount = optNumber("splice", args[1], "delete count", node) ?? 0
|
||||
const deleteCount = optNumber("splice", args[1], "delete count") ?? 0
|
||||
const inserted = args.slice(2)
|
||||
for (const item of inserted) rejectCircularInsertion(target, item, "Array.splice result", node)
|
||||
for (const item of inserted) rejectCircularInsertion(target, item, "Array.splice result")
|
||||
return wrap(target.items.splice(start, deleteCount, ...inserted))
|
||||
},
|
||||
],
|
||||
[
|
||||
"toSpliced",
|
||||
2,
|
||||
(thisValue, args, node) => {
|
||||
const copied = [...self(thisValue, "toSpliced", node).items]
|
||||
(thisValue, args) => {
|
||||
const copied = [...self(thisValue, "toSpliced").items]
|
||||
if (args.length === 0) return wrap(copied)
|
||||
const start = optNumber("toSpliced", args[0], "start", node) ?? 0
|
||||
const start = optNumber("toSpliced", args[0], "start") ?? 0
|
||||
if (args.length === 1) copied.splice(start)
|
||||
else copied.splice(start, optNumber("toSpliced", args[1], "delete count", node) ?? 0, ...args.slice(2))
|
||||
else copied.splice(start, optNumber("toSpliced", args[1], "delete count") ?? 0, ...args.slice(2))
|
||||
return wrap(copied)
|
||||
},
|
||||
],
|
||||
[
|
||||
"fill",
|
||||
1,
|
||||
(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))
|
||||
(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"))
|
||||
return target
|
||||
},
|
||||
],
|
||||
[
|
||||
"copyWithin",
|
||||
2,
|
||||
(thisValue, args, node) => {
|
||||
const target = self(thisValue, "copyWithin", node)
|
||||
(thisValue, args) => {
|
||||
const target = self(thisValue, "copyWithin")
|
||||
target.items.copyWithin(
|
||||
optNumber("copyWithin", args[0], "target index", node) ?? 0,
|
||||
optNumber("copyWithin", args[1], "start", node) ?? 0,
|
||||
optNumber("copyWithin", args[2], "end", node),
|
||||
optNumber("copyWithin", args[0], "target index") ?? 0,
|
||||
optNumber("copyWithin", args[1], "start") ?? 0,
|
||||
optNumber("copyWithin", args[2], "end"),
|
||||
)
|
||||
return target
|
||||
},
|
||||
],
|
||||
["keys", 0, (thisValue, _, node) => wrap(Array.from(self(thisValue, "keys", node).items.keys()))],
|
||||
["values", 0, (thisValue, _, node) => wrap([...self(thisValue, "values", node).items])],
|
||||
["keys", 0, (thisValue) => wrap(Array.from(self(thisValue, "keys").items.keys()))],
|
||||
["values", 0, (thisValue) => wrap([...self(thisValue, "values").items])],
|
||||
[
|
||||
"entries",
|
||||
0,
|
||||
(thisValue, _, node) =>
|
||||
wrap(Array.from(self(thisValue, "entries", node).items.entries(), ([index, item]) => wrap([index, item]))),
|
||||
(thisValue) =>
|
||||
wrap(Array.from(self(thisValue, "entries").items.entries(), ([index, item]) => wrap([index, item]))),
|
||||
],
|
||||
iterate("map", 1, (target, receiver, apply) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -455,7 +443,7 @@ export const arrayGlobal = <R>(runner: Runner<R>) => {
|
||||
return undefined
|
||||
}),
|
||||
),
|
||||
iterate("reduce", 1, (target, receiver, apply, args, node) =>
|
||||
iterate("reduce", 1, (target, receiver, apply, args) =>
|
||||
Effect.gen(function* () {
|
||||
const length = target.length
|
||||
let start = 0
|
||||
@@ -463,7 +451,7 @@ export const arrayGlobal = <R>(runner: Runner<R>) => {
|
||||
if (args.length < 2) {
|
||||
while (start < length && !(start in target)) start += 1
|
||||
if (start === length) {
|
||||
throw new InterpreterRuntimeError("Array.reduce of an empty array with no initial value.", node)
|
||||
throw typeError("Array.reduce of an empty array with no initial value.")
|
||||
}
|
||||
accumulator = target[start]
|
||||
start += 1
|
||||
@@ -475,14 +463,14 @@ export const arrayGlobal = <R>(runner: Runner<R>) => {
|
||||
return accumulator
|
||||
}),
|
||||
),
|
||||
iterate("reduceRight", 1, (target, receiver, apply, args, node) =>
|
||||
iterate("reduceRight", 1, (target, receiver, apply, args) =>
|
||||
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 new InterpreterRuntimeError("Array.reduceRight of an empty array with no initial value.", node)
|
||||
throw typeError("Array.reduceRight of an empty array with no initial value.")
|
||||
}
|
||||
accumulator = target[start]
|
||||
start -= 1
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Effect } from "effect"
|
||||
import { constructor, fn, type Method, methods, prototypeFrom, receiver, requiresNew } from "../interpreter/native.js"
|
||||
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
|
||||
import { invalidData, typeError } from "../interpreter/model.js"
|
||||
import {
|
||||
define,
|
||||
defineAccessor,
|
||||
@@ -24,35 +24,27 @@ import {
|
||||
toPrimitiveString,
|
||||
} from "../interpreter/runner.js"
|
||||
|
||||
const coerceGroupByPropertyKey = <R>(
|
||||
runner: Runner<R>,
|
||||
value: unknown,
|
||||
node: AstNode,
|
||||
): Effect.Effect<string, unknown, R> => {
|
||||
const coerceGroupByPropertyKey = <R>(runner: Runner<R>, value: unknown): Effect.Effect<string, unknown, R> => {
|
||||
if (value instanceof ProgramPromise) return Effect.succeed("[object Promise]")
|
||||
if (!isWrapper(value) && isRuntimeReference(value)) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`Object.groupBy callback must return a data value, received ${describeValue(value)}.`,
|
||||
node,
|
||||
"InvalidDataValue",
|
||||
)
|
||||
throw invalidData(`Object.groupBy callback must return a data value, received ${describeValue(value)}.`)
|
||||
}
|
||||
return toPrimitiveString(runner, value, node)
|
||||
return toPrimitiveString(runner, value)
|
||||
}
|
||||
|
||||
/** `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, node) => {
|
||||
fn<R>(runner.prototypes, "groupBy", 2, (_, args) => {
|
||||
const protos = runner.prototypes
|
||||
const source = args[0]
|
||||
if (source === null || source === undefined) {
|
||||
throw new InterpreterRuntimeError(`${namespace}.groupBy expects an iterable collection.`, node)
|
||||
throw typeError(`${namespace}.groupBy expects an iterable collection.`)
|
||||
}
|
||||
const apply = applyCollectionCallback(runner, args[1], `${namespace}.groupBy`, node)
|
||||
const apply = applyCollectionCallback(runner, args[1], `${namespace}.groupBy`)
|
||||
return Effect.gen(function* () {
|
||||
const cursor = yield* runner.syncIterator(source, node)
|
||||
const cursor = yield* runner.syncIterator(source)
|
||||
if (cursor === undefined) {
|
||||
throw new InterpreterRuntimeError(`${namespace}.groupBy expects an iterable collection.`, node)
|
||||
throw typeError(`${namespace}.groupBy expects an iterable collection.`)
|
||||
}
|
||||
if (namespace === "Map") {
|
||||
const result = new ProgramMap(protos.Map)
|
||||
@@ -78,7 +70,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, node)),
|
||||
Effect.flatMap(apply([item, index]), (value) => coerceGroupByPropertyKey(runner, value)),
|
||||
)
|
||||
const group = getOwn(result, key)
|
||||
if (group === undefined) define(result, key, new ProgramArray(protos.Array, [item]))
|
||||
@@ -88,13 +80,13 @@ export const groupBy = <R>(runner: Runner<R>, namespace: "Map" | "Object") =>
|
||||
})
|
||||
})
|
||||
|
||||
const constructMap = <R>(runner: Runner<R>, init: unknown, proto: ProgramObject, node: AstNode) => {
|
||||
const constructMap = <R>(runner: Runner<R>, init: unknown, proto: ProgramObject) => {
|
||||
const target = new ProgramMap(proto)
|
||||
if (init === undefined || init === null) return Effect.succeed(target)
|
||||
return Effect.gen(function* () {
|
||||
const cursor = yield* runner.syncIterator(init, node)
|
||||
const cursor = yield* runner.syncIterator(init)
|
||||
if (cursor === undefined) {
|
||||
throw new InterpreterRuntimeError("new Map(...) expects an iterable of [key, value] pairs or no argument.", node)
|
||||
throw typeError("new Map(...) expects an iterable of [key, value] pairs or no argument.")
|
||||
}
|
||||
while (true) {
|
||||
const step = yield* cursor.next
|
||||
@@ -103,7 +95,7 @@ const constructMap = <R>(runner: Runner<R>, init: unknown, proto: ProgramObject,
|
||||
cursor,
|
||||
Effect.sync(() => {
|
||||
if (!(step.value instanceof ProgramObject)) {
|
||||
throw new InterpreterRuntimeError("new Map(...) expects [key, value] pairs as entry objects.", node)
|
||||
throw typeError("new Map(...) expects [key, value] pairs as entry objects.")
|
||||
}
|
||||
target.map.set(getOwn(step.value, 0), getOwn(step.value, 1))
|
||||
}),
|
||||
@@ -112,13 +104,13 @@ const constructMap = <R>(runner: Runner<R>, init: unknown, proto: ProgramObject,
|
||||
})
|
||||
}
|
||||
|
||||
const constructSet = <R>(runner: Runner<R>, init: unknown, proto: ProgramObject, node: AstNode) => {
|
||||
const constructSet = <R>(runner: Runner<R>, init: unknown, proto: ProgramObject) => {
|
||||
const target = new ProgramSet(proto)
|
||||
if (init === undefined || init === null) return Effect.succeed(target)
|
||||
return Effect.gen(function* () {
|
||||
const cursor = yield* runner.syncIterator(init, node)
|
||||
const cursor = yield* runner.syncIterator(init)
|
||||
if (cursor === undefined) {
|
||||
throw new InterpreterRuntimeError("new Set(...) expects a synchronous iterable or no argument.", node)
|
||||
throw typeError("new Set(...) expects a synchronous iterable or no argument.")
|
||||
}
|
||||
while (true) {
|
||||
const step = yield* cursor.next
|
||||
@@ -134,48 +126,46 @@ export const mapGlobal = <R>(runner: Runner<R>) => {
|
||||
const map = constructor<R>(protos, proto, {
|
||||
name: "Map",
|
||||
call: requiresNew("Map"),
|
||||
construct: (args, newTarget, node) => constructMap(runner, args[0], prototypeFrom(newTarget, proto), node),
|
||||
construct: (args, newTarget) => constructMap(runner, args[0], prototypeFrom(newTarget, proto)),
|
||||
})
|
||||
define(map, "groupBy", groupBy(runner, "Map"), hidden)
|
||||
const self = (thisValue: unknown, name: string, node: AstNode) =>
|
||||
receiver(ProgramMap, thisValue, `Map.prototype.${name}`, node)
|
||||
const self = (thisValue: unknown, name: string) => receiver(ProgramMap, thisValue, `Map.prototype.${name}`)
|
||||
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, node) => self(thisValue, "get", node).map.get(args[0])],
|
||||
["has", 1, (thisValue, args, node) => self(thisValue, "has", node).map.has(args[0])],
|
||||
["get", 1, (thisValue, args) => self(thisValue, "get").map.get(args[0])],
|
||||
["has", 1, (thisValue, args) => self(thisValue, "has").map.has(args[0])],
|
||||
[
|
||||
"set",
|
||||
2,
|
||||
(thisValue, args, node) => {
|
||||
const target = self(thisValue, "set", node)
|
||||
(thisValue, args) => {
|
||||
const target = self(thisValue, "set")
|
||||
target.map.set(args[0], args[1])
|
||||
return target
|
||||
},
|
||||
],
|
||||
["delete", 1, (thisValue, args, node) => self(thisValue, "delete", node).map.delete(args[0])],
|
||||
["delete", 1, (thisValue, args) => self(thisValue, "delete").map.delete(args[0])],
|
||||
[
|
||||
"clear",
|
||||
0,
|
||||
(thisValue, _, node) => {
|
||||
self(thisValue, "clear", node).map.clear()
|
||||
(thisValue) => {
|
||||
self(thisValue, "clear").map.clear()
|
||||
return undefined
|
||||
},
|
||||
],
|
||||
["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()))],
|
||||
["keys", 0, (thisValue) => wrap(Array.from(self(thisValue, "keys").map.keys()))],
|
||||
["values", 0, (thisValue) => wrap(Array.from(self(thisValue, "values").map.values()))],
|
||||
[
|
||||
"entries",
|
||||
0,
|
||||
(thisValue, _, node) =>
|
||||
wrap(Array.from(self(thisValue, "entries", node).map.entries(), ([key, item]) => wrap([key, item]))),
|
||||
(thisValue) => wrap(Array.from(self(thisValue, "entries").map.entries(), ([key, item]) => wrap([key, item]))),
|
||||
],
|
||||
[
|
||||
"forEach",
|
||||
1,
|
||||
(thisValue, args, node) => {
|
||||
const target = self(thisValue, "forEach", node)
|
||||
const apply = applyCollectionCallback(runner, args[0], "Map.forEach", node)
|
||||
(thisValue, args) => {
|
||||
const target = self(thisValue, "forEach")
|
||||
const apply = applyCollectionCallback(runner, args[0], "Map.forEach")
|
||||
return Effect.gen(function* () {
|
||||
for (const [key, item] of Array.from(target.map.entries())) yield* apply([item, key, target])
|
||||
return undefined
|
||||
@@ -196,7 +186,6 @@ 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({
|
||||
@@ -213,25 +202,25 @@ const loadSetRecord = <R>(
|
||||
})
|
||||
}
|
||||
if (!(source instanceof ProgramObject)) {
|
||||
throw new InterpreterRuntimeError(`Set.${name} expects a Set-like object.`, node)
|
||||
throw typeError(`Set.${name} expects a Set-like object.`)
|
||||
}
|
||||
return Effect.gen(function* () {
|
||||
const size = yield* toPrimitiveNumber(runner, get(source, "size"), node)
|
||||
const size = yield* toPrimitiveNumber(runner, get(source, "size"))
|
||||
if (Number.isNaN(size)) {
|
||||
throw new InterpreterRuntimeError(`Set.${name} received a Set-like object with an invalid size.`, node)
|
||||
throw typeError(`Set.${name} received a Set-like object with an invalid size.`)
|
||||
}
|
||||
const has = get(source, "has")
|
||||
const keys = get(source, "keys")
|
||||
if (!isSupportedCallback(has) || !isSupportedCallback(keys)) {
|
||||
throw new InterpreterRuntimeError(`Set.${name} expects callable 'has' and 'keys' methods.`, node)
|
||||
throw typeError(`Set.${name} expects callable 'has' and 'keys' methods.`)
|
||||
}
|
||||
return {
|
||||
size: Math.max(Math.trunc(size), 0),
|
||||
has: (item: unknown) => Effect.map(runner.invokeCallable(has, source, [item], node), Boolean),
|
||||
has: (item: unknown) => Effect.map(runner.invokeCallable(has, source, [item]), Boolean),
|
||||
keys: () =>
|
||||
Effect.flatMap(runner.invokeCallable(keys, source, [], node), (result) => {
|
||||
Effect.flatMap(runner.invokeCallable(keys, source, []), (result) => {
|
||||
if (result instanceof ProgramArray) return Effect.succeed(result.items)
|
||||
throw new InterpreterRuntimeError(`Set.${name} expected 'keys' to return an iterator.`, node)
|
||||
throw typeError(`Set.${name} expected 'keys' to return an iterator.`)
|
||||
}),
|
||||
}
|
||||
})
|
||||
@@ -242,10 +231,9 @@ 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, node)
|
||||
const other = yield* loadSetRecord(runner, source, name)
|
||||
const copy = () => {
|
||||
const result = new ProgramSet(runner.prototypes.Set)
|
||||
for (const item of target.set.values()) result.set.add(item)
|
||||
@@ -320,51 +308,49 @@ export const setGlobal = <R>(runner: Runner<R>) => {
|
||||
const set = constructor<R>(protos, proto, {
|
||||
name: "Set",
|
||||
call: requiresNew("Set"),
|
||||
construct: (args, newTarget, node) => constructSet(runner, args[0], prototypeFrom(newTarget, proto), node),
|
||||
construct: (args, newTarget) => constructSet(runner, args[0], prototypeFrom(newTarget, proto)),
|
||||
})
|
||||
const self = (thisValue: unknown, name: string, node: AstNode) =>
|
||||
receiver(ProgramSet, thisValue, `Set.prototype.${name}`, node)
|
||||
const self = (thisValue: unknown, name: string) => receiver(ProgramSet, thisValue, `Set.prototype.${name}`)
|
||||
const wrap = (items: Array<unknown>) => new ProgramArray(protos.Array, items)
|
||||
const operation = (name: string): Method => [
|
||||
name,
|
||||
1,
|
||||
(thisValue, args, node) => setOperation(runner, self(thisValue, name, node), name, args[0], node),
|
||||
(thisValue, args) => setOperation(runner, self(thisValue, name), name, args[0]),
|
||||
]
|
||||
defineAccessor(proto, "size", (thisValue) => receiver(ProgramSet, thisValue, "Set.prototype.size").set.size)
|
||||
methods(protos, proto, [
|
||||
["has", 1, (thisValue, args, node) => self(thisValue, "has", node).set.has(args[0])],
|
||||
["has", 1, (thisValue, args) => self(thisValue, "has").set.has(args[0])],
|
||||
[
|
||||
"add",
|
||||
1,
|
||||
(thisValue, args, node) => {
|
||||
const target = self(thisValue, "add", node)
|
||||
(thisValue, args) => {
|
||||
const target = self(thisValue, "add")
|
||||
target.set.add(args[0])
|
||||
return target
|
||||
},
|
||||
],
|
||||
["delete", 1, (thisValue, args, node) => self(thisValue, "delete", node).set.delete(args[0])],
|
||||
["delete", 1, (thisValue, args) => self(thisValue, "delete").set.delete(args[0])],
|
||||
[
|
||||
"clear",
|
||||
0,
|
||||
(thisValue, _, node) => {
|
||||
self(thisValue, "clear", node).set.clear()
|
||||
(thisValue) => {
|
||||
self(thisValue, "clear").set.clear()
|
||||
return undefined
|
||||
},
|
||||
],
|
||||
["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()))],
|
||||
["keys", 0, (thisValue) => wrap(Array.from(self(thisValue, "keys").set.values()))],
|
||||
["values", 0, (thisValue) => wrap(Array.from(self(thisValue, "values").set.values()))],
|
||||
[
|
||||
"entries",
|
||||
0,
|
||||
(thisValue, _, node) =>
|
||||
wrap(Array.from(self(thisValue, "entries", node).set.values(), (item) => wrap([item, item]))),
|
||||
(thisValue) => wrap(Array.from(self(thisValue, "entries").set.values(), (item) => wrap([item, item]))),
|
||||
],
|
||||
[
|
||||
"forEach",
|
||||
1,
|
||||
(thisValue, args, node) => {
|
||||
const target = self(thisValue, "forEach", node)
|
||||
const apply = applyCollectionCallback(runner, args[0], "Set.forEach", node)
|
||||
(thisValue, args) => {
|
||||
const target = self(thisValue, "forEach")
|
||||
const apply = applyCollectionCallback(runner, args[0], "Set.forEach")
|
||||
return Effect.gen(function* () {
|
||||
for (const item of Array.from(target.set.values())) yield* apply([item, item, target])
|
||||
return undefined
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { Effect } from "effect"
|
||||
import { constructor, type Method, methods, prototypeFrom, receiver } from "../interpreter/native.js"
|
||||
import { type AstNode, rangeError } from "../interpreter/model.js"
|
||||
import { 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, node: AstNode) => {
|
||||
const constructDate = <R>(runner: Runner<R>, args: Array<unknown>, proto: ProgramObject) => {
|
||||
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", node), (value) =>
|
||||
return Effect.map(toPrimitive(runner, arg, "default"), (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, node) => constructDate(runner, args, prototypeFrom(newTarget, proto), node),
|
||||
construct: (args, newTarget) => constructDate(runner, args, prototypeFrom(newTarget, proto)),
|
||||
})
|
||||
methods(protos, date, [
|
||||
["now", 0, () => Date.now()],
|
||||
@@ -82,42 +82,39 @@ 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, 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)
|
||||
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.")
|
||||
return new Date(value.time).toISOString()
|
||||
}
|
||||
methods(protos, proto, [
|
||||
["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)],
|
||||
["getTime", 0, (thisValue) => self(thisValue, "getTime").time],
|
||||
["valueOf", 0, (thisValue) => self(thisValue, "valueOf").time],
|
||||
["toISOString", 0, (thisValue) => iso(self(thisValue, "toISOString"))],
|
||||
[
|
||||
"toJSON",
|
||||
1,
|
||||
(thisValue, _, node) => {
|
||||
const value = self(thisValue, "toJSON", node)
|
||||
return Number.isFinite(value.time) ? iso(value, node) : null
|
||||
(thisValue) => {
|
||||
const value = self(thisValue, "toJSON")
|
||||
return Number.isFinite(value.time) ? iso(value) : null
|
||||
},
|
||||
],
|
||||
["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]()],
|
||||
),
|
||||
["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]()]),
|
||||
...setters.map(
|
||||
([name, length]): Method => [
|
||||
name,
|
||||
length,
|
||||
(thisValue, args, node) => {
|
||||
const target = self(thisValue, name, node)
|
||||
(thisValue, args) => {
|
||||
const target = self(thisValue, name)
|
||||
// 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, node), {
|
||||
Effect.forEach(args.slice(0, length), (arg) => toPrimitiveNumber(runner, arg), {
|
||||
concurrency: 1,
|
||||
}),
|
||||
(values) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Effect } from "effect"
|
||||
import { methods } from "../interpreter/native.js"
|
||||
import { applyCollectionCallback, type Runner } from "../interpreter/runner.js"
|
||||
import { type AstNode, InterpreterRuntimeError, syntaxError } from "../interpreter/model.js"
|
||||
import { syntaxError, typeError } 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,29 +9,26 @@ 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, node) => parse(runner, args, node)],
|
||||
["stringify", 3, (_, args, node) => stringify(runner, args, node)],
|
||||
["parse", 2, (_, args) => parse(runner, args)],
|
||||
["stringify", 3, (_, args) => stringify(runner, args)],
|
||||
])
|
||||
return json
|
||||
}
|
||||
|
||||
const parse = <R>(runner: Runner<R>, args: Array<unknown>, node: AstNode): Effect.Effect<unknown, unknown, R> => {
|
||||
const parse = <R>(runner: Runner<R>, args: Array<unknown>): Effect.Effect<unknown, unknown, R> => {
|
||||
const text = args[0]
|
||||
if (typeof text !== "string") throw new InterpreterRuntimeError("JSON.parse expects a string.", node)
|
||||
if (typeof text !== "string") throw typeError("JSON.parse expects a string.")
|
||||
|
||||
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)}`,
|
||||
node,
|
||||
)
|
||||
throw syntaxError(`JSON.parse received invalid JSON: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
})()
|
||||
if (typeofValue(args[1]) !== "function") return Effect.succeed(parsed)
|
||||
|
||||
const apply = applyCollectionCallback(runner, args[1], "JSON.parse", node)
|
||||
const apply = applyCollectionCallback(runner, args[1], "JSON.parse")
|
||||
const visit = (holder: ProgramObject, key: string): Effect.Effect<unknown, unknown, R> =>
|
||||
Effect.gen(function* () {
|
||||
const value = get(holder, key)
|
||||
@@ -47,7 +44,7 @@ const parse = <R>(runner: Runner<R>, args: Array<unknown>, node: AstNode): Effec
|
||||
return visit(record(runner.prototypes.Object, { "": parsed }), "")
|
||||
}
|
||||
|
||||
const stringify = <R>(runner: Runner<R>, args: Array<unknown>, node: AstNode): Effect.Effect<unknown, unknown, R> => {
|
||||
const stringify = <R>(runner: Runner<R>, args: Array<unknown>): Effect.Effect<unknown, unknown, R> => {
|
||||
const space = args[2]
|
||||
const indent = typeof space === "number" || typeof space === "string" ? space : undefined
|
||||
const replacer = args[1]
|
||||
@@ -64,17 +61,17 @@ const stringify = <R>(runner: Runner<R>, args: Array<unknown>, node: AstNode): E
|
||||
|
||||
// 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", node)
|
||||
const apply = applyCollectionCallback(runner, replacer, "JSON.stringify")
|
||||
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, node)])
|
||||
const value = yield* apply([key, yield* toJSONValue(runner, get(holder, key), key)])
|
||||
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 new InterpreterRuntimeError("Converting circular structure to JSON.", node)
|
||||
if (stack.has(value)) throw typeError("Converting circular structure to JSON.")
|
||||
stack.add(value)
|
||||
if (value instanceof ProgramArray) {
|
||||
const result: Array<unknown> = []
|
||||
@@ -99,8 +96,8 @@ const stringify = <R>(runner: Runner<R>, args: Array<unknown>, node: AstNode): E
|
||||
}
|
||||
|
||||
// SerializeJSONProperty step 2: a callable `toJSON` decides the value, as Date and URL define.
|
||||
const toJSONValue = <R>(runner: Runner<R>, value: unknown, key: string, node: AstNode) => {
|
||||
const toJSONValue = <R>(runner: Runner<R>, value: unknown, key: string) => {
|
||||
if (!(value instanceof ProgramObject)) return Effect.succeed(value)
|
||||
const toJSON = get(value, "toJSON")
|
||||
return toJSON instanceof Callable ? runner.invokeCallable(toJSON, value, [key], node) : Effect.succeed(value)
|
||||
return toJSON instanceof Callable ? runner.invokeCallable(toJSON, value, [key]) : Effect.succeed(value)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Effect } from "effect"
|
||||
import { constants, type Method, methods } from "../interpreter/native.js"
|
||||
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
|
||||
import { typeError } from "../interpreter/model.js"
|
||||
import { ProgramObject } from "../interpreter/objects.js"
|
||||
import { preserveConsumerError, type Runner } from "../interpreter/runner.js"
|
||||
|
||||
@@ -13,32 +13,28 @@ 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, node: AstNode): number => {
|
||||
const number = (name: string, args: Array<unknown>, index: number): number => {
|
||||
if (index >= args.length) return Number.NaN
|
||||
const arg = args[index]
|
||||
if (typeof arg !== "number") throw new InterpreterRuntimeError(`Math.${name} expects number arguments.`, node)
|
||||
if (typeof arg !== "number") throw typeError(`Math.${name} expects number arguments.`)
|
||||
return arg
|
||||
}
|
||||
|
||||
const unary = (name: string, op: (a: number) => number): Method => [
|
||||
name,
|
||||
1,
|
||||
(_, args, node) => op(number(name, args, 0, node)),
|
||||
]
|
||||
const unary = (name: string, op: (a: number) => number): Method => [name, 1, (_, args) => op(number(name, args, 0))]
|
||||
|
||||
const binary = (name: string, op: (a: number, b: number) => number): Method => [
|
||||
name,
|
||||
2,
|
||||
(_, args, node) => op(number(name, args, 0, node), number(name, args, 1, node)),
|
||||
(_, args) => op(number(name, args, 0), number(name, args, 1)),
|
||||
]
|
||||
|
||||
const variadic = (name: string, op: (...values: Array<number>) => number): Method => [
|
||||
name,
|
||||
2,
|
||||
(_, args, node) =>
|
||||
(_, args) =>
|
||||
op(
|
||||
...args.map((arg) => {
|
||||
if (typeof arg !== "number") throw new InterpreterRuntimeError(`Math.${name} expects number arguments.`, node)
|
||||
if (typeof arg !== "number") throw typeError(`Math.${name} expects number arguments.`)
|
||||
return arg
|
||||
}),
|
||||
),
|
||||
@@ -97,11 +93,11 @@ export const mathGlobal = <R>(runner: Runner<R>) => {
|
||||
[
|
||||
"sumPrecise",
|
||||
1,
|
||||
(_, args, node) =>
|
||||
(_, args) =>
|
||||
Effect.gen(function* () {
|
||||
const cursor = yield* runner.syncIterator(args[0], node)
|
||||
const cursor = yield* runner.syncIterator(args[0])
|
||||
if (cursor === undefined) {
|
||||
throw new InterpreterRuntimeError("Math.sumPrecise expects a synchronous iterable.", node)
|
||||
throw typeError("Math.sumPrecise expects a synchronous iterable.")
|
||||
}
|
||||
const numbers: Array<number> = []
|
||||
while (true) {
|
||||
@@ -111,7 +107,7 @@ export const mathGlobal = <R>(runner: Runner<R>) => {
|
||||
cursor,
|
||||
Effect.sync(() => {
|
||||
if (typeof step.value !== "number") {
|
||||
throw new InterpreterRuntimeError("Math.sumPrecise expects an iterable of numbers.", node)
|
||||
throw typeError("Math.sumPrecise expects an iterable of numbers.")
|
||||
}
|
||||
numbers.push(step.value)
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { constructor, constants, methods } from "../interpreter/native.js"
|
||||
import { type AstNode, InterpreterRuntimeError, rangeError } from "../interpreter/model.js"
|
||||
import { rangeError, typeError } 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, node) => {
|
||||
(_, args) => {
|
||||
const radix = args[1]
|
||||
if (radix !== undefined && typeof radix !== "number") {
|
||||
throw new InterpreterRuntimeError("Number.parseInt expects a numeric radix.", node)
|
||||
throw typeError("Number.parseInt expects a numeric radix.")
|
||||
}
|
||||
return parseInt(coerceToString(args[0]), radix)
|
||||
},
|
||||
@@ -39,49 +39,44 @@ export const numberGlobal = <R>(runner: Runner<R>) => {
|
||||
["parseFloat", 1, (_, args) => parseFloat(coerceToString(args[0]))],
|
||||
])
|
||||
|
||||
const self = (thisValue: unknown, name: string, node: AstNode): number => {
|
||||
const self = (thisValue: unknown, name: string): number => {
|
||||
if (typeof thisValue === "number") return thisValue
|
||||
throw new InterpreterRuntimeError(`Number.prototype.${name} requires that 'this' be a Number.`, node)
|
||||
throw typeError(`Number.prototype.${name} requires that 'this' be a Number.`)
|
||||
}
|
||||
const optNum = (name: string, arg: unknown, node: AstNode): number | undefined => {
|
||||
const optNum = (name: string, arg: unknown): number | undefined => {
|
||||
if (arg === undefined) return undefined
|
||||
if (typeof arg !== "number") throw new InterpreterRuntimeError(`Number.${name} expects a number argument.`, node)
|
||||
if (typeof arg !== "number") throw typeError(`Number.${name} expects a number argument.`)
|
||||
return arg
|
||||
}
|
||||
methods(protos, protos.Number, [
|
||||
[
|
||||
"toFixed",
|
||||
1,
|
||||
(thisValue, args, node) => self(thisValue, "toFixed", node).toFixed(optNum("toFixed", args[0], node)),
|
||||
],
|
||||
["toFixed", 1, (thisValue, args) => self(thisValue, "toFixed").toFixed(optNum("toFixed", args[0]))],
|
||||
[
|
||||
"toExponential",
|
||||
1,
|
||||
(thisValue, args, node) =>
|
||||
self(thisValue, "toExponential", node).toExponential(optNum("toExponential", args[0], node)),
|
||||
(thisValue, args) => self(thisValue, "toExponential").toExponential(optNum("toExponential", args[0])),
|
||||
],
|
||||
[
|
||||
"toPrecision",
|
||||
1,
|
||||
(thisValue, args, node) => {
|
||||
const value = self(thisValue, "toPrecision", node)
|
||||
const digits = optNum("toPrecision", args[0], node)
|
||||
(thisValue, args) => {
|
||||
const value = self(thisValue, "toPrecision")
|
||||
const digits = optNum("toPrecision", args[0])
|
||||
return digits === undefined ? value.toString() : value.toPrecision(digits)
|
||||
},
|
||||
],
|
||||
[
|
||||
"toString",
|
||||
1,
|
||||
(thisValue, args, node) => {
|
||||
const value = self(thisValue, "toString", node)
|
||||
const radix = optNum("toString", args[0], node)
|
||||
(thisValue, args) => {
|
||||
const value = self(thisValue, "toString")
|
||||
const radix = optNum("toString", args[0])
|
||||
if (radix !== undefined && (radix < 2 || radix > 36)) {
|
||||
throw rangeError("Number.toString radix must be between 2 and 36.", node)
|
||||
throw rangeError("Number.toString radix must be between 2 and 36.")
|
||||
}
|
||||
return value.toString(radix)
|
||||
},
|
||||
],
|
||||
["valueOf", 0, (thisValue, _, node) => self(thisValue, "valueOf", node)],
|
||||
["valueOf", 0, (thisValue) => self(thisValue, "valueOf")],
|
||||
])
|
||||
return number
|
||||
}
|
||||
@@ -93,13 +88,13 @@ export const booleanGlobal = <R>(runner: Runner<R>) => {
|
||||
length: 1,
|
||||
call: coercion(runner, "Boolean").call,
|
||||
})
|
||||
const self = (thisValue: unknown, name: string, node: AstNode): boolean => {
|
||||
const self = (thisValue: unknown, name: string): boolean => {
|
||||
if (typeof thisValue === "boolean") return thisValue
|
||||
throw new InterpreterRuntimeError(`Boolean.prototype.${name} requires that 'this' be a Boolean.`, node)
|
||||
throw typeError(`Boolean.prototype.${name} requires that 'this' be a Boolean.`)
|
||||
}
|
||||
methods(protos, protos.Boolean, [
|
||||
["toString", 0, (thisValue, _, node) => String(self(thisValue, "toString", node))],
|
||||
["valueOf", 0, (thisValue, _, node) => self(thisValue, "valueOf", node)],
|
||||
["toString", 0, (thisValue) => String(self(thisValue, "toString"))],
|
||||
["valueOf", 0, (thisValue) => self(thisValue, "valueOf")],
|
||||
])
|
||||
return boolean
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@ import { constructor, methods, receiver } from "../interpreter/native.js"
|
||||
import {
|
||||
type AstNode,
|
||||
AsyncIteratorSymbol,
|
||||
InterpreterRuntimeError,
|
||||
invalidData,
|
||||
IteratorSymbol,
|
||||
rangeError,
|
||||
typeError,
|
||||
} from "../interpreter/model.js"
|
||||
import {
|
||||
Callable,
|
||||
@@ -34,22 +35,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 new InterpreterRuntimeError(`${label} cannot convert ${describeValue(value)} to an object.`, node)
|
||||
throw typeError(`${label} cannot convert ${describeValue(value)} to an object.`, node)
|
||||
}
|
||||
if (value instanceof ProgramPromise) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`${label} received an un-awaited Promise; await it before inspecting the result.`,
|
||||
node,
|
||||
"InvalidDataValue",
|
||||
)
|
||||
throw invalidData(`${label} received an un-awaited Promise; await it before inspecting the result.`, node)
|
||||
}
|
||||
if (value instanceof ToolReference) {
|
||||
throw new InterpreterRuntimeError(
|
||||
throw invalidData(
|
||||
`${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])
|
||||
@@ -57,40 +58,33 @@ export const enumerableSource = <R>(runner: Runner<R>, label: string, value: unk
|
||||
return new ProgramObject(runner.prototypes.Object)
|
||||
}
|
||||
|
||||
export const objectAssign = <R>(runner: Runner<R>, args: Array<unknown>, node: AstNode): unknown => {
|
||||
export const objectAssign = <R>(runner: Runner<R>, args: Array<unknown>): unknown => {
|
||||
const target = args[0]
|
||||
// JS would box a primitive target; wrappers and primitives cannot hold fields here.
|
||||
if (!(target instanceof ProgramObject)) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`Object.assign expects a data object or array target, received ${describeValue(target)}.`,
|
||||
node,
|
||||
)
|
||||
throw typeError(`Object.assign expects a data object or array target, received ${describeValue(target)}.`)
|
||||
}
|
||||
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, node)
|
||||
const from = enumerableSource(runner, "Object.assign(...)", source)
|
||||
for (const key of enumerableKeys(from)) {
|
||||
rejectCircularInsertion(target, getOwn(from, key), "Object.assign result", node, seen)
|
||||
rejectCircularInsertion(target, getOwn(from, key), "Object.assign result", seen)
|
||||
if (!set(target, key, getOwn(from, 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)
|
||||
if (target instanceof ProgramArray && key === "length") throw rangeError("Invalid array length")
|
||||
throw typeError(`Cannot assign to read only property '${String(key)}'.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
const objectFromEntries = <R>(
|
||||
runner: Runner<R>,
|
||||
source: unknown,
|
||||
node: AstNode,
|
||||
): Effect.Effect<ProgramObject, unknown, R> => {
|
||||
const objectFromEntries = <R>(runner: Runner<R>, source: unknown): Effect.Effect<ProgramObject, unknown, R> => {
|
||||
const out = new ProgramObject(runner.prototypes.Object)
|
||||
return Effect.gen(function* () {
|
||||
const cursor = yield* runner.syncIterator(source, node)
|
||||
const cursor = yield* runner.syncIterator(source)
|
||||
if (cursor === undefined) {
|
||||
throw new InterpreterRuntimeError("Object.fromEntries expects a synchronous iterable of entries.", node)
|
||||
throw typeError("Object.fromEntries expects a synchronous iterable of entries.")
|
||||
}
|
||||
while (true) {
|
||||
const step = yield* cursor.next
|
||||
@@ -99,7 +93,7 @@ const objectFromEntries = <R>(
|
||||
cursor,
|
||||
Effect.sync(() => {
|
||||
if (!(step.value instanceof ProgramObject) || containsOpaqueReference(step.value)) {
|
||||
throw new InterpreterRuntimeError("Object.fromEntries expects [key, value] entry objects.", node)
|
||||
throw typeError("Object.fromEntries expects [key, value] entry objects.")
|
||||
}
|
||||
define(out, coerceToString(getOwn(step.value, 0)), getOwn(step.value, 1))
|
||||
}),
|
||||
@@ -132,91 +126,83 @@ export const objectGlobal = <R>(
|
||||
toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>,
|
||||
) => {
|
||||
const protos = runner.prototypes
|
||||
const construct = (args: Array<unknown>, node: AstNode): unknown => {
|
||||
const construct = (args: Array<unknown>): unknown => {
|
||||
const first = args[0]
|
||||
if (first === null || first === undefined) return new ProgramObject(protos.Object)
|
||||
if (typeof first === "object") return first
|
||||
throw new InterpreterRuntimeError(
|
||||
`Object(${typeof first}) wrapper objects are not supported; use the primitive value directly.`,
|
||||
node,
|
||||
)
|
||||
throw typeError(`Object(${typeof first}) wrapper objects are not supported; use the primitive value directly.`)
|
||||
}
|
||||
const object = constructor<R>(protos, protos.Object, {
|
||||
name: "Object",
|
||||
length: 1,
|
||||
call: (_, args, node) => Effect.sync(() => construct(args, node)),
|
||||
construct: (args, _, node) => Effect.sync(() => construct(args, node)),
|
||||
call: (_, args) => Effect.sync(() => construct(args)),
|
||||
construct: (args) => Effect.sync(() => construct(args)),
|
||||
})
|
||||
methods(protos, object, [
|
||||
[
|
||||
"keys",
|
||||
1,
|
||||
(_, args, node) =>
|
||||
(_, args) =>
|
||||
toProgram(
|
||||
protos,
|
||||
args[0] instanceof ToolReference
|
||||
? [...toolKeys(args[0].path)]
|
||||
: keys(enumerableSource(runner, "Object.keys(...)", args[0], node)),
|
||||
: keys(enumerableSource(runner, "Object.keys(...)", args[0])),
|
||||
"Object.keys result",
|
||||
),
|
||||
],
|
||||
[
|
||||
"values",
|
||||
1,
|
||||
(_, args, node) =>
|
||||
(_, args) =>
|
||||
new ProgramArray(
|
||||
protos.Array,
|
||||
entries(enumerableSource(runner, "Object.values(...)", args[0], node)).map((entry) => entry[1]),
|
||||
entries(enumerableSource(runner, "Object.values(...)", args[0])).map((entry) => entry[1]),
|
||||
),
|
||||
],
|
||||
[
|
||||
"entries",
|
||||
1,
|
||||
(_, args, node) =>
|
||||
(_, args) =>
|
||||
new ProgramArray(
|
||||
protos.Array,
|
||||
entries(enumerableSource(runner, "Object.entries(...)", args[0], node)).map(
|
||||
entries(enumerableSource(runner, "Object.entries(...)", args[0])).map(
|
||||
(entry) => new ProgramArray(protos.Array, entry),
|
||||
),
|
||||
),
|
||||
],
|
||||
[
|
||||
"hasOwn",
|
||||
2,
|
||||
(_, args, node) => hasOwn(enumerableSource(runner, "Object.hasOwn(...)", args[0], node), propertyKey(args[1])),
|
||||
],
|
||||
["hasOwn", 2, (_, args) => hasOwn(enumerableSource(runner, "Object.hasOwn(...)", args[0]), propertyKey(args[1]))],
|
||||
[
|
||||
"is",
|
||||
2,
|
||||
(_, args, node) => {
|
||||
(_, args) => {
|
||||
if (containsOpaqueReference(args[0]) || containsOpaqueReference(args[1])) {
|
||||
throw new InterpreterRuntimeError("Object.is requires data values.", node, "InvalidDataValue")
|
||||
throw invalidData("Object.is requires data values.")
|
||||
}
|
||||
return Object.is(args[0], args[1])
|
||||
},
|
||||
],
|
||||
["assign", 2, (_, args, node) => objectAssign(runner, args, node)],
|
||||
["fromEntries", 1, (_, args, node) => objectFromEntries(runner, args[0], node)],
|
||||
["assign", 2, (_, args) => objectAssign(runner, args)],
|
||||
["fromEntries", 1, (_, args) => objectFromEntries(runner, args[0])],
|
||||
])
|
||||
define(object, "groupBy", groupBy(runner, "Object"), hidden)
|
||||
methods(protos, protos.Object, [
|
||||
[
|
||||
"hasOwnProperty",
|
||||
1,
|
||||
(thisValue, args, node) =>
|
||||
hasOwn(receiver(ProgramObject, thisValue, "Object.prototype.hasOwnProperty", node), propertyKey(args[0])),
|
||||
(thisValue, args) =>
|
||||
hasOwn(receiver(ProgramObject, thisValue, "Object.prototype.hasOwnProperty"), propertyKey(args[0])),
|
||||
],
|
||||
[
|
||||
"isPrototypeOf",
|
||||
1,
|
||||
(thisValue, args, node) =>
|
||||
hasPrototype(args[0], receiver(ProgramObject, thisValue, "Object.prototype.isPrototypeOf", node)),
|
||||
(thisValue, args) => hasPrototype(args[0], receiver(ProgramObject, thisValue, "Object.prototype.isPrototypeOf")),
|
||||
],
|
||||
[
|
||||
"propertyIsEnumerable",
|
||||
1,
|
||||
(thisValue, args, node) =>
|
||||
own(receiver(ProgramObject, thisValue, "Object.prototype.propertyIsEnumerable", node), propertyKey(args[0]))
|
||||
(thisValue, args) =>
|
||||
own(receiver(ProgramObject, thisValue, "Object.prototype.propertyIsEnumerable"), propertyKey(args[0]))
|
||||
?.enumerable === true,
|
||||
],
|
||||
["toString", 0, (thisValue) => `[object ${classTag(thisValue)}]`],
|
||||
@@ -224,9 +210,9 @@ export const objectGlobal = <R>(
|
||||
[
|
||||
"valueOf",
|
||||
0,
|
||||
(thisValue, _, node) => {
|
||||
(thisValue) => {
|
||||
if (thisValue === null || thisValue === undefined) {
|
||||
throw new InterpreterRuntimeError("Object.prototype.valueOf called on null or undefined.", node)
|
||||
throw typeError("Object.prototype.valueOf called on null or undefined.")
|
||||
}
|
||||
return thisValue
|
||||
},
|
||||
|
||||
@@ -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 { type AstNode, InterpreterRuntimeError, syntaxError } from "../interpreter/model.js"
|
||||
import { syntaxError, typeError } 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, node: AstNode, extraFlags = ""): RegExp => {
|
||||
export const toHostRegex = (arg: unknown, method: string, 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,13 +42,11 @@ export const toHostRegex = (arg: unknown, method: string, node: AstNode, extraFl
|
||||
} 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 new InterpreterRuntimeError(
|
||||
throw typeError(
|
||||
`String.${method} expects a regular expression (a /pattern/flags literal or new RegExp(...)) or a string pattern, not ${arg === null ? "null" : typeof arg}.`,
|
||||
node,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -67,7 +65,6 @@ 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]
|
||||
@@ -76,7 +73,6 @@ 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 : "")
|
||||
@@ -88,7 +84,6 @@ 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -106,23 +101,21 @@ export const regexpGlobal = <R>(runner: Runner<R>) => {
|
||||
const regexp = constructor<R>(protos, proto, {
|
||||
name: "RegExp",
|
||||
length: 2,
|
||||
call: (_, args, node) => Effect.sync(() => constructRegExp(protos, args, node)),
|
||||
construct: (args, newTarget, node) =>
|
||||
Effect.sync(() => constructRegExp(protos, args, node, prototypeFrom(newTarget, proto))),
|
||||
call: (_, args) => Effect.sync(() => constructRegExp(protos, args)),
|
||||
construct: (args, newTarget) => Effect.sync(() => constructRegExp(protos, args, prototypeFrom(newTarget, proto))),
|
||||
})
|
||||
methods(protos, regexp, [
|
||||
[
|
||||
"escape",
|
||||
1,
|
||||
(_, args, node) => {
|
||||
if (typeof args[0] !== "string") throw new InterpreterRuntimeError("RegExp.escape expects a string.", node)
|
||||
(_, args) => {
|
||||
if (typeof args[0] !== "string") throw typeError("RegExp.escape expects a string.")
|
||||
return RegExp.escape(args[0])
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
const self = (thisValue: unknown, name: string, node?: AstNode) =>
|
||||
receiver(ProgramRegExp, thisValue, `RegExp.prototype.${name}`, node)
|
||||
const self = (thisValue: unknown, name: string) => receiver(ProgramRegExp, thisValue, `RegExp.prototype.${name}`)
|
||||
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])
|
||||
@@ -130,8 +123,8 @@ export const regexpGlobal = <R>(runner: Runner<R>) => {
|
||||
const run = (name: "exec" | "test"): Method => [
|
||||
name,
|
||||
1,
|
||||
(thisValue, args, node) => {
|
||||
const value = self(thisValue, name, node)
|
||||
(thisValue, args) => {
|
||||
const value = self(thisValue, name)
|
||||
const input = coerceToString(args[0])
|
||||
const stateful = value.regex.global || value.regex.sticky
|
||||
value.regex.lastIndex = toLength(getOwn(value, "lastIndex"))
|
||||
@@ -144,7 +137,7 @@ export const regexpGlobal = <R>(runner: Runner<R>) => {
|
||||
methods(protos, proto, [
|
||||
run("exec"),
|
||||
run("test"),
|
||||
["toString", 0, (thisValue, _, node) => coerceToString(self(thisValue, "toString", node))],
|
||||
["toString", 0, (thisValue) => coerceToString(self(thisValue, "toString"))],
|
||||
])
|
||||
return regexp
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Effect } from "effect"
|
||||
import { toProgram } from "../data.js"
|
||||
import { constructor, type Method, methods } from "../interpreter/native.js"
|
||||
import { type AstNode, InterpreterRuntimeError, rangeError } from "../interpreter/model.js"
|
||||
import { invalidData, rangeError, typeError } 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,22 +9,17 @@ 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, node: AstNode): unknown => {
|
||||
const requireDataArgument = (name: string, index: number, arg: unknown): unknown => {
|
||||
if (containsOpaqueReference(arg)) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`String.${name} expects argument ${index + 1} to be a data value.`,
|
||||
node,
|
||||
"InvalidDataValue",
|
||||
)
|
||||
throw invalidData(`String.${name} expects argument ${index + 1} to be a data value.`)
|
||||
}
|
||||
return arg
|
||||
}
|
||||
|
||||
const replaceAllNeedsGlobal = (pattern: RegExp, node: AstNode) => {
|
||||
const replaceAllNeedsGlobal = (pattern: RegExp) => {
|
||||
if (!pattern.global) {
|
||||
throw new InterpreterRuntimeError(
|
||||
throw typeError(
|
||||
`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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -34,10 +29,9 @@ 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}`, node)
|
||||
const apply = applyCollectionCallback(runner, args[1], `String.${name}`)
|
||||
const matches: Array<{ readonly match: string; readonly offset: number; readonly args: Array<unknown> }> = []
|
||||
const collect = (...callbackArgs: Array<unknown>): string => {
|
||||
const match = callbackArgs[0]
|
||||
@@ -45,7 +39,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 new InterpreterRuntimeError(`String.${name} produced an invalid replacement match.`, node)
|
||||
throw typeError(`String.${name} produced an invalid replacement match.`)
|
||||
}
|
||||
if (hasGroups) callbackArgs[callbackArgs.length - 1] = record(protos.Object, groups as Record<string, unknown>)
|
||||
matches.push({ match, offset, args: callbackArgs })
|
||||
@@ -54,11 +48,11 @@ const replaceWithCallback = <R>(
|
||||
|
||||
const pattern = args[0]
|
||||
if (pattern instanceof ProgramRegExp) {
|
||||
if (name === "replaceAll") replaceAllNeedsGlobal(pattern.regex, node)
|
||||
if (name === "replaceAll") replaceAllNeedsGlobal(pattern.regex)
|
||||
if (name === "replace") value.replace(pattern.regex, collect)
|
||||
else value.replaceAll(pattern.regex, collect)
|
||||
} else {
|
||||
const search = coerceToString(requireDataArgument(name, 0, pattern, node))
|
||||
const search = coerceToString(requireDataArgument(name, 0, pattern))
|
||||
if (name === "replace") value.replace(search, collect)
|
||||
else value.replaceAll(search, collect)
|
||||
}
|
||||
@@ -91,11 +85,11 @@ export const stringGlobal = <R>(runner: Runner<R>) => {
|
||||
const codeUnits = (name: string, op: (...codes: Array<number>) => string): Method => [
|
||||
name,
|
||||
1,
|
||||
(_, args, node) =>
|
||||
(_, args) =>
|
||||
op(
|
||||
...args.map((arg) => {
|
||||
if (typeof arg !== "number") {
|
||||
throw new InterpreterRuntimeError(`String.${name} expects number arguments.`, node)
|
||||
throw typeError(`String.${name} expects number arguments.`)
|
||||
}
|
||||
return arg
|
||||
}),
|
||||
@@ -106,52 +100,50 @@ export const stringGlobal = <R>(runner: Runner<R>) => {
|
||||
codeUnits("fromCodePoint", String.fromCodePoint),
|
||||
])
|
||||
|
||||
const self = (thisValue: unknown, name: string, node: AstNode): string => {
|
||||
const self = (thisValue: unknown, name: string): string => {
|
||||
if (typeof thisValue === "string") return thisValue
|
||||
if (thisValue === null || thisValue === undefined) {
|
||||
throw new InterpreterRuntimeError(`String.prototype.${name} called on null or undefined.`, node)
|
||||
throw typeError(`String.prototype.${name} called on null or undefined.`)
|
||||
}
|
||||
return coerceToString(thisValue)
|
||||
}
|
||||
// Coerce arguments like native JS; opaque runtime references still reject.
|
||||
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 => {
|
||||
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 => {
|
||||
if (args[0] instanceof ProgramRegExp) {
|
||||
throw new InterpreterRuntimeError(
|
||||
throw typeError(
|
||||
`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>, node: AstNode) => unknown,
|
||||
): Method => [name, length, (thisValue, args, node) => op(self(thisValue, name, node), args, 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 replace = (name: "replace" | "replaceAll") =>
|
||||
simple(name, 2, (value, args, node) => {
|
||||
if (isSupportedCallback(args[1])) return replaceWithCallback(runner, value, name, args, node)
|
||||
simple(name, 2, (value, args) => {
|
||||
if (isSupportedCallback(args[1])) return replaceWithCallback(runner, value, name, args)
|
||||
if (typeofValue(args[1]) === "function") {
|
||||
throw new InterpreterRuntimeError(
|
||||
throw typeError(
|
||||
`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, node)
|
||||
if (name === "replaceAll") replaceAllNeedsGlobal(pattern, node)
|
||||
const replacement = str(name, args, 1)
|
||||
if (name === "replaceAll") replaceAllNeedsGlobal(pattern)
|
||||
return name === "replace" ? value.replace(pattern, replacement) : value.replaceAll(pattern, replacement)
|
||||
}
|
||||
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))
|
||||
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))
|
||||
})
|
||||
|
||||
methods(protos, protos.String, [
|
||||
@@ -165,68 +157,60 @@ 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, node) => value.localeCompare(str("localeCompare", args, 0, node))),
|
||||
simple("normalize", 0, (value, args, node) => {
|
||||
const form = optStr("normalize", args, 0, node)
|
||||
simple("localeCompare", 1, (value, args) => value.localeCompare(str("localeCompare", args, 0))),
|
||||
simple("normalize", 0, (value, args) => {
|
||||
const form = optStr("normalize", args, 0)
|
||||
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, node) => {
|
||||
simple("split", 2, (value, args) => {
|
||||
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, node)
|
||||
const requestedLimit = optNum("split", args, 1)
|
||||
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, node), requestedLimit === undefined ? undefined : requestedLimit >>> 0),
|
||||
)
|
||||
return wrap(value.split(str("split", args, 0), requestedLimit === undefined ? undefined : requestedLimit >>> 0))
|
||||
}),
|
||||
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("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("startsWith", 1, (value, args, node) => {
|
||||
rejectRegex("startsWith", args, node)
|
||||
return value.startsWith(str("startsWith", args, 0, node), optNum("startsWith", args, 1, node))
|
||||
simple("startsWith", 1, (value, args) => {
|
||||
rejectRegex("startsWith", args)
|
||||
return value.startsWith(str("startsWith", args, 0), optNum("startsWith", 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("endsWith", 1, (value, args) => {
|
||||
rejectRegex("endsWith", args)
|
||||
return value.endsWith(str("endsWith", args, 0), optNum("endsWith", 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)),
|
||||
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)),
|
||||
),
|
||||
replace("replace"),
|
||||
replace("replaceAll"),
|
||||
simple("match", 1, (value, args, node) => {
|
||||
const pattern = toHostRegex(args[0], "match", node)
|
||||
simple("match", 1, (value, args) => {
|
||||
const pattern = toHostRegex(args[0], "match")
|
||||
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, node) => {
|
||||
const pattern = toHostRegex(args[0], "matchAll", node, "g")
|
||||
simple("matchAll", 1, (value, args) => {
|
||||
const pattern = toHostRegex(args[0], "matchAll", "g")
|
||||
if (!pattern.global) {
|
||||
throw new InterpreterRuntimeError(
|
||||
throw typeError(
|
||||
`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(
|
||||
@@ -234,35 +218,27 @@ export const stringGlobal = <R>(runner: Runner<R>) => {
|
||||
Array.from(value.matchAll(pattern), (match) => matchToValue(protos, match)),
|
||||
)
|
||||
}),
|
||||
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)
|
||||
simple("search", 1, (value, args) => value.search(toHostRegex(args[0], "search"))),
|
||||
simple("repeat", 1, (value, args) => {
|
||||
const count = num("repeat", args, 0)
|
||||
if (!Number.isFinite(count) || count < 0) {
|
||||
throw rangeError("String.repeat expects a finite non-negative count.", node)
|
||||
throw rangeError("String.repeat expects a finite non-negative count.")
|
||||
}
|
||||
return value.repeat(count)
|
||||
}),
|
||||
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("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("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, 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))),
|
||||
),
|
||||
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)))),
|
||||
])
|
||||
return string
|
||||
}
|
||||
|
||||
@@ -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 { type AstNode, InterpreterRuntimeError, uriError } from "../interpreter/model.js"
|
||||
import { PendingThrow, typeError, uriError } from "../interpreter/model.js"
|
||||
import {
|
||||
defineAccessor,
|
||||
entries,
|
||||
@@ -43,15 +43,12 @@ const uriFunctions: Record<UriFunction, (value: string) => string> = {
|
||||
}
|
||||
|
||||
export const uriGlobal = <R>(runner: Runner<R>, name: UriFunction) =>
|
||||
fn<R>(runner.prototypes, name, 1, (_, args, node) => {
|
||||
fn<R>(runner.prototypes, name, 1, (_, args) => {
|
||||
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)}`,
|
||||
node,
|
||||
)
|
||||
throw uriError(`${name} received malformed URI data: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -61,32 +58,29 @@ 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, node: AstNode): ProgramURL => {
|
||||
const construct = (args: Array<unknown>, into: ProgramObject): ProgramURL => {
|
||||
if (args.length === 0) {
|
||||
throw new InterpreterRuntimeError("new URL(...) requires a URL string and an optional base URL.", node)
|
||||
throw typeError("new URL(...) requires a URL string and an optional base URL.")
|
||||
}
|
||||
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 new InterpreterRuntimeError(
|
||||
`new URL(...) received an invalid URL${base === undefined ? "" : " or base URL"}.`,
|
||||
node,
|
||||
)
|
||||
throw typeError(`new URL(...) received an invalid URL${base === undefined ? "" : " or base URL"}.`)
|
||||
}
|
||||
}
|
||||
const url = constructor<R>(protos, proto, {
|
||||
name: "URL",
|
||||
length: 1,
|
||||
call: requiresNew("URL"),
|
||||
construct: (args, newTarget, node) => Effect.sync(() => construct(args, prototypeFrom(newTarget, proto), node)),
|
||||
construct: (args, newTarget) => Effect.sync(() => construct(args, prototypeFrom(newTarget, proto))),
|
||||
})
|
||||
const parse = (name: "canParse" | "parse"): Method => [
|
||||
name,
|
||||
1,
|
||||
(_, args, node) => {
|
||||
if (args.length === 0) throw new InterpreterRuntimeError(`URL.${name} requires a URL argument.`, node)
|
||||
(_, args) => {
|
||||
if (args.length === 0) throw typeError(`URL.${name} requires a URL argument.`)
|
||||
const input = urlArgument(protos, args[0], `URL.${name} input`)
|
||||
const base = args[1] === undefined ? undefined : urlArgument(protos, args[1], `URL.${name} base`)
|
||||
try {
|
||||
@@ -99,8 +93,7 @@ export const urlGlobal = <R>(runner: Runner<R>) => {
|
||||
]
|
||||
methods(protos, url, [parse("canParse"), parse("parse")])
|
||||
|
||||
const self = (thisValue: unknown, name: string, node?: AstNode) =>
|
||||
receiver(ProgramURL, thisValue, `URL.prototype.${name}`, node)
|
||||
const self = (thisValue: unknown, name: string) => receiver(ProgramURL, thisValue, `URL.prototype.${name}`)
|
||||
for (const name of urlProperties) {
|
||||
defineAccessor(
|
||||
proto,
|
||||
@@ -113,25 +106,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 InterpreterRuntimeError || error instanceof ToolRuntimeError) throw error
|
||||
throw new InterpreterRuntimeError(`URL.${name} received an invalid value.`)
|
||||
if (error instanceof PendingThrow || error instanceof ToolRuntimeError) throw error
|
||||
throw typeError(`URL.${name} received an invalid value.`)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
defineAccessor(proto, "searchParams", (thisValue) => self(thisValue, "searchParams").searchParams)
|
||||
methods(protos, proto, [
|
||||
["toString", 0, (thisValue, _, node) => self(thisValue, "toString", node).url.href],
|
||||
["toJSON", 0, (thisValue, _, node) => self(thisValue, "toJSON", node).url.href],
|
||||
["toString", 0, (thisValue) => self(thisValue, "toString").url.href],
|
||||
["toJSON", 0, (thisValue) => self(thisValue, "toJSON").url.href],
|
||||
])
|
||||
return url
|
||||
}
|
||||
|
||||
const readPair = <R>(runner: Runner<R>, value: unknown, node: AstNode): Effect.Effect<Array<string>, unknown, R> =>
|
||||
const readPair = <R>(runner: Runner<R>, value: unknown): Effect.Effect<Array<string>, unknown, R> =>
|
||||
Effect.gen(function* () {
|
||||
const cursor = yield* runner.syncIterator(value, node)
|
||||
const cursor = yield* runner.syncIterator(value)
|
||||
if (cursor === undefined) {
|
||||
throw new InterpreterRuntimeError("new URLSearchParams(...) expects iterable [name, value] pairs.", node)
|
||||
throw typeError("new URLSearchParams(...) expects iterable [name, value] pairs.")
|
||||
}
|
||||
const items: Array<string> = []
|
||||
while (true) {
|
||||
@@ -150,7 +143,6 @@ 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()))
|
||||
@@ -160,31 +152,27 @@ const constructURLSearchParams = <R>(
|
||||
return Effect.succeed(wrap(new URLSearchParams(coerceToString(init))))
|
||||
}
|
||||
return Effect.gen(function* () {
|
||||
const cursor = yield* runner.syncIterator(init, node)
|
||||
const cursor = yield* runner.syncIterator(init)
|
||||
if (cursor !== undefined) {
|
||||
const pairs: Array<Array<string>> = []
|
||||
while (true) {
|
||||
const step = yield* cursor.next
|
||||
if (step.done) {
|
||||
if (pairs.some((entry) => entry.length !== 2)) {
|
||||
throw new InterpreterRuntimeError("new URLSearchParams(...) expects iterable [name, value] pairs.", node)
|
||||
throw typeError("new URLSearchParams(...) expects iterable [name, value] pairs.")
|
||||
}
|
||||
return wrap(new URLSearchParams(pairs.map((entry): [string, string] => [entry[0] ?? "", entry[1] ?? ""])))
|
||||
}
|
||||
pairs.push(yield* preserveConsumerError(cursor, readPair(runner, step.value, node)))
|
||||
pairs.push(yield* preserveConsumerError(cursor, readPair(runner, step.value)))
|
||||
}
|
||||
}
|
||||
if (isRuntimeReference(init)) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"new URLSearchParams(...) expects a query string, data object, or synchronous iterable pairs.",
|
||||
node,
|
||||
)
|
||||
throw typeError("new URLSearchParams(...) expects a query string, data object, or synchronous iterable pairs.")
|
||||
}
|
||||
if (isWrapper(init)) return wrap(new URLSearchParams())
|
||||
if (!(init instanceof ProgramObject)) {
|
||||
throw new InterpreterRuntimeError(
|
||||
throw typeError(
|
||||
"new URLSearchParams(...) expects a query string, data object, iterable pairs, or URLSearchParams.",
|
||||
node,
|
||||
)
|
||||
}
|
||||
return wrap(
|
||||
@@ -199,20 +187,16 @@ export const urlSearchParamsGlobal = <R>(runner: Runner<R>) => {
|
||||
const searchParams = constructor<R>(protos, proto, {
|
||||
name: "URLSearchParams",
|
||||
call: requiresNew("URLSearchParams"),
|
||||
construct: (args, newTarget, node) =>
|
||||
constructURLSearchParams(runner, args[0], prototypeFrom(newTarget, proto), node),
|
||||
construct: (args, newTarget) => constructURLSearchParams(runner, args[0], prototypeFrom(newTarget, proto)),
|
||||
})
|
||||
const self = (thisValue: unknown, name: string, node?: AstNode) =>
|
||||
receiver(ProgramURLSearchParams, thisValue, `URLSearchParams.prototype.${name}`, node)
|
||||
const self = (thisValue: unknown, name: string) =>
|
||||
receiver(ProgramURLSearchParams, thisValue, `URLSearchParams.prototype.${name}`)
|
||||
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, node: AstNode): void => {
|
||||
const requireArgs = (name: string, args: Array<unknown>, count: number): void => {
|
||||
if (args.length < count) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`URLSearchParams.${name} requires ${count} argument${count === 1 ? "" : "s"}.`,
|
||||
node,
|
||||
)
|
||||
throw typeError(`URLSearchParams.${name} requires ${count} argument${count === 1 ? "" : "s"}.`)
|
||||
}
|
||||
}
|
||||
defineAccessor(proto, "size", (thisValue) => self(thisValue, "size").params.size)
|
||||
@@ -220,18 +204,18 @@ export const urlSearchParamsGlobal = <R>(runner: Runner<R>) => {
|
||||
[
|
||||
"append",
|
||||
2,
|
||||
(thisValue, args, node) => {
|
||||
requireArgs("append", args, 2, node)
|
||||
self(thisValue, "append", node).params.append(arg("append", args, 0), arg("append", args, 1))
|
||||
(thisValue, args) => {
|
||||
requireArgs("append", args, 2)
|
||||
self(thisValue, "append").params.append(arg("append", args, 0), arg("append", args, 1))
|
||||
return undefined
|
||||
},
|
||||
],
|
||||
[
|
||||
"delete",
|
||||
1,
|
||||
(thisValue, args, node) => {
|
||||
requireArgs("delete", args, 1, node)
|
||||
const params = self(thisValue, "delete", node).params
|
||||
(thisValue, args) => {
|
||||
requireArgs("delete", args, 1)
|
||||
const params = self(thisValue, "delete").params
|
||||
if (args[1] !== undefined) params.delete(arg("delete", args, 0), arg("delete", args, 1))
|
||||
else params.delete(arg("delete", args, 0))
|
||||
return undefined
|
||||
@@ -240,25 +224,25 @@ export const urlSearchParamsGlobal = <R>(runner: Runner<R>) => {
|
||||
[
|
||||
"get",
|
||||
1,
|
||||
(thisValue, args, node) => {
|
||||
requireArgs("get", args, 1, node)
|
||||
return self(thisValue, "get", node).params.get(arg("get", args, 0))
|
||||
(thisValue, args) => {
|
||||
requireArgs("get", args, 1)
|
||||
return self(thisValue, "get").params.get(arg("get", args, 0))
|
||||
},
|
||||
],
|
||||
[
|
||||
"getAll",
|
||||
1,
|
||||
(thisValue, args, node) => {
|
||||
requireArgs("getAll", args, 1, node)
|
||||
return wrap(self(thisValue, "getAll", node).params.getAll(arg("getAll", args, 0)))
|
||||
(thisValue, args) => {
|
||||
requireArgs("getAll", args, 1)
|
||||
return wrap(self(thisValue, "getAll").params.getAll(arg("getAll", args, 0)))
|
||||
},
|
||||
],
|
||||
[
|
||||
"has",
|
||||
1,
|
||||
(thisValue, args, node) => {
|
||||
requireArgs("has", args, 1, node)
|
||||
const params = self(thisValue, "has", node).params
|
||||
(thisValue, args) => {
|
||||
requireArgs("has", args, 1)
|
||||
const params = self(thisValue, "has").params
|
||||
return args[1] !== undefined
|
||||
? params.has(arg("has", args, 0), arg("has", args, 1))
|
||||
: params.has(arg("has", args, 0))
|
||||
@@ -267,36 +251,36 @@ export const urlSearchParamsGlobal = <R>(runner: Runner<R>) => {
|
||||
[
|
||||
"set",
|
||||
2,
|
||||
(thisValue, args, node) => {
|
||||
requireArgs("set", args, 2, node)
|
||||
self(thisValue, "set", node).params.set(arg("set", args, 0), arg("set", args, 1))
|
||||
(thisValue, args) => {
|
||||
requireArgs("set", args, 2)
|
||||
self(thisValue, "set").params.set(arg("set", args, 0), arg("set", args, 1))
|
||||
return undefined
|
||||
},
|
||||
],
|
||||
[
|
||||
"sort",
|
||||
0,
|
||||
(thisValue, _, node) => {
|
||||
self(thisValue, "sort", node).params.sort()
|
||||
(thisValue) => {
|
||||
self(thisValue, "sort").params.sort()
|
||||
return undefined
|
||||
},
|
||||
],
|
||||
["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()))],
|
||||
["keys", 0, (thisValue) => wrap(Array.from(self(thisValue, "keys").params.keys()))],
|
||||
["values", 0, (thisValue) => wrap(Array.from(self(thisValue, "values").params.values()))],
|
||||
[
|
||||
"entries",
|
||||
0,
|
||||
(thisValue, _, node) =>
|
||||
wrap(Array.from(self(thisValue, "entries", node).params.entries(), ([key, value]) => wrap([key, value]))),
|
||||
(thisValue) =>
|
||||
wrap(Array.from(self(thisValue, "entries").params.entries(), ([key, value]) => wrap([key, value]))),
|
||||
],
|
||||
["toString", 0, (thisValue, _, node) => self(thisValue, "toString", node).params.toString()],
|
||||
["toString", 0, (thisValue) => self(thisValue, "toString").params.toString()],
|
||||
[
|
||||
"forEach",
|
||||
1,
|
||||
(thisValue, args, node) => {
|
||||
requireArgs("forEach", args, 1, node)
|
||||
const target = self(thisValue, "forEach", node)
|
||||
const apply = applyCollectionCallback(runner, args[0], "URLSearchParams.forEach", node)
|
||||
(thisValue, args) => {
|
||||
requireArgs("forEach", args, 1)
|
||||
const target = self(thisValue, "forEach")
|
||||
const apply = applyCollectionCallback(runner, args[0], "URLSearchParams.forEach")
|
||||
return Effect.gen(function* () {
|
||||
for (const [key, value] of Array.from(target.params.entries())) yield* apply([value, key, target])
|
||||
return undefined
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { toProgram } from "../data.js"
|
||||
import { fn } from "../interpreter/native.js"
|
||||
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
|
||||
import { typeError } 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>, node: AstNode): unknown => {
|
||||
const coerce = <R>(runner: Runner<R>, name: Coercion, args: Array<unknown>): 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>, node
|
||||
if (name === "parseInt") {
|
||||
const radix = args[1]
|
||||
if (radix !== undefined && typeof radix !== "number") {
|
||||
throw new InterpreterRuntimeError("parseInt expects a numeric radix.", node)
|
||||
throw typeError("parseInt expects a numeric radix.")
|
||||
}
|
||||
return parseInt(coerceToString(value), radix)
|
||||
}
|
||||
@@ -90,6 +90,6 @@ const coerce = <R>(runner: Runner<R>, name: Coercion, args: Array<unknown>, node
|
||||
|
||||
/** 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, node) =>
|
||||
toProgram(runner.prototypes, coerce(runner, name, args, node), `${name} result`),
|
||||
fn(runner.prototypes, name, length, (_, args) =>
|
||||
toProgram(runner.prototypes, coerce(runner, name, args), `${name} result`),
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fn, methods } from "../interpreter/native.js"
|
||||
import { InterpreterRuntimeError } from "../interpreter/model.js"
|
||||
import { typeError } 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, node) => {
|
||||
if (args.length === 0) throw new InterpreterRuntimeError(`${name} requires 1 argument (a string)`, node)
|
||||
fn<R>(runner.prototypes, name, 1, (_, args) => {
|
||||
if (args.length === 0) throw typeError(`${name} requires 1 argument (a string)`)
|
||||
const input = coerceToString(args[0])
|
||||
try {
|
||||
return name === "atob" ? atob(input) : btoa(input)
|
||||
} catch {
|
||||
throw new InterpreterRuntimeError("The string contains invalid characters.", node)
|
||||
throw typeError("The string contains invalid characters.")
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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("Uncaught: boom")
|
||||
expect(result.ok ? undefined : result.error.message).toBe("Error: boom")
|
||||
})
|
||||
|
||||
test("prints NaN and Infinity literally instead of the JSON null", async () => {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
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)")
|
||||
})
|
||||
})
|
||||
@@ -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: Uncaught: boom" },
|
||||
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Error: 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: Uncaught: boom" },
|
||||
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Error: 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: Uncaught: first" },
|
||||
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Error: first" },
|
||||
{ kind: "ToolFailure", message: "Unhandled rejection from an un-awaited promise: Lookup refused" },
|
||||
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: third" },
|
||||
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Error: 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: Uncaught: outer" },
|
||||
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: inner" },
|
||||
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Error: outer" },
|
||||
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Error: inner" },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -445,7 +445,7 @@ describe("first-class promise values", () => {
|
||||
)
|
||||
expect(result.ok).toBe(false)
|
||||
if (result.ok) return
|
||||
expect(result.error.message).toBe("Uncaught: boom")
|
||||
expect(result.error.message).toBe("Error: 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: Uncaught: abandoned" },
|
||||
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Error: 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: Uncaught: boom" },
|
||||
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Error: boom" },
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// test262's own assert.js relies on.
|
||||
import path from "node:path"
|
||||
import { Cause, Effect } from "effect"
|
||||
import { caughtErrorValue } from "../../src/interpreter/errors.js"
|
||||
import { materialize } 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, node) => {
|
||||
(_, args) => {
|
||||
const expected = args[0] instanceof Callable ? String(get(args[0], "name")) : show(args[0])
|
||||
return host.runner.invokeCallable(args[1], undefined, [], node).pipe(
|
||||
return host.runner.invokeCallable(args[1], undefined, []).pipe(
|
||||
Effect.matchCauseEffect({
|
||||
onFailure: (cause) => {
|
||||
if (cause.reasons.some(Cause.isInterruptReason)) return Effect.failCause(cause)
|
||||
const thrown = caughtErrorValue(host.runner, Cause.squash(cause))
|
||||
const thrown = materialize(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,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"name": "@opencode/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
+75
-64
@@ -9,6 +9,7 @@ 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,
|
||||
@@ -308,7 +309,7 @@ const layer = Layer.effect(
|
||||
operationName: OperationError["operation"],
|
||||
repository: Repository,
|
||||
args: string[],
|
||||
options?: { stdin?: string; env?: Record<string, string> },
|
||||
options?: { stdin?: string; env?: Record<string, string>; maxOutputBytes?: number },
|
||||
) {
|
||||
const result = yield* proc
|
||||
.run(
|
||||
@@ -317,7 +318,7 @@ const layer = Layer.effect(
|
||||
env: options?.env,
|
||||
extendEnv: true,
|
||||
}),
|
||||
{ stdin: options?.stdin },
|
||||
{ stdin: options?.stdin, maxOutputBytes: options?.maxOutputBytes },
|
||||
)
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
@@ -331,7 +332,8 @@ const layer = Layer.effect(
|
||||
),
|
||||
)
|
||||
const text = result.stdout.toString("utf8")
|
||||
if (result.exitCode === 0) return { text, stderr: result.stderr.toString("utf8") }
|
||||
if (result.exitCode === 0)
|
||||
return { text, stderr: result.stderr.toString("utf8"), truncated: result.stdoutTruncated }
|
||||
return yield* new OperationError({
|
||||
operation: operationName,
|
||||
directory: repository.worktree,
|
||||
@@ -385,9 +387,7 @@ const layer = Layer.effect(
|
||||
maximumUntrackedFileBytes?: number
|
||||
}) {
|
||||
const list = (args: string[]) =>
|
||||
repositoryOperation("refresh", input.repository, args).pipe(
|
||||
Effect.map((result) => result.text.split("\0").filter(Boolean)),
|
||||
)
|
||||
repositoryOperation("refresh", input.repository, args).pipe(Effect.map((result) => nuls(result.text)))
|
||||
const [tracked, untracked] = yield* Effect.all(
|
||||
[
|
||||
list(["diff-files", "--name-only", "-z", "--", input.scope]),
|
||||
@@ -464,13 +464,7 @@ const layer = Layer.effect(
|
||||
directory: input.repository.worktree,
|
||||
message: result.stderr.toString("utf8").trim() || "Failed to check ignored paths",
|
||||
})
|
||||
return new Set(
|
||||
result.stdout
|
||||
.toString("utf8")
|
||||
.split("\0")
|
||||
.filter(Boolean)
|
||||
.map((file) => RelativePath.make(file)),
|
||||
)
|
||||
return new Set(nuls(result.stdout.toString("utf8")).map((file) => RelativePath.make(file)))
|
||||
})
|
||||
|
||||
const writeTree = Effect.fn("Git.tree.write")(function* (repository: Repository) {
|
||||
@@ -499,19 +493,23 @@ const layer = Layer.effect(
|
||||
to: TreeID
|
||||
}) {
|
||||
// Undo needs both paths of a rename, not only its destination.
|
||||
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))
|
||||
return nuls(
|
||||
(yield* repositoryOperation("list_files", input.repository, [
|
||||
"diff",
|
||||
"--name-only",
|
||||
"--no-renames",
|
||||
"-z",
|
||||
input.from,
|
||||
input.to,
|
||||
])).text,
|
||||
).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
|
||||
@@ -519,49 +517,57 @@ const layer = Layer.effect(
|
||||
context?: number
|
||||
paths?: readonly RelativePath[]
|
||||
}) {
|
||||
const paths = input.paths ?? (yield* treeFiles(input))
|
||||
return yield* Effect.forEach(paths, (file) =>
|
||||
Effect.gen(function* () {
|
||||
const statusText = (yield* repositoryOperation("diff", input.repository, [
|
||||
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(
|
||||
"diff",
|
||||
"--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
|
||||
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,
|
||||
]
|
||||
}),
|
||||
)
|
||||
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) {
|
||||
@@ -733,6 +739,11 @@ 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
|
||||
|
||||
@@ -55,8 +55,11 @@ 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"
|
||||
@@ -109,6 +112,7 @@ 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<{
|
||||
@@ -135,6 +139,13 @@ 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
|
||||
@@ -227,6 +238,7 @@ 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)
|
||||
|
||||
@@ -359,6 +371,17 @@ 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),
|
||||
@@ -448,6 +471,7 @@ export const node: LayerNode.Provider<Service, never, typeof Node.tags.values.gl
|
||||
SessionInbox.node,
|
||||
SessionMove.node,
|
||||
SessionProjector.node,
|
||||
LocationServiceMap.node,
|
||||
FSUtil.node,
|
||||
App.node,
|
||||
],
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
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)))
|
||||
})
|
||||
@@ -60,6 +60,21 @@ 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")({
|
||||
@@ -124,9 +139,11 @@ 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": () => clearCurrentRetry,
|
||||
"session.execution.failed": () => clearCurrentRetry,
|
||||
"session.execution.interrupted": () => clearCurrentRetry,
|
||||
"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.instructions.updated": (event) => {
|
||||
if (event.data.text === undefined) return Effect.void
|
||||
return adapter.appendMessage(
|
||||
|
||||
@@ -226,6 +226,7 @@ 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 [
|
||||
|
||||
@@ -131,38 +131,55 @@ const layer = Layer.effect(
|
||||
)
|
||||
})
|
||||
|
||||
const compare = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
|
||||
const comparison = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
|
||||
const repo = yield* repository.pipe(Effect.mapError((cause) => failure(operation, cause)))
|
||||
const comparison = {
|
||||
return {
|
||||
source: repo.source,
|
||||
repository: repo.snapshotRepository,
|
||||
from: Git.TreeID.make(input.from),
|
||||
to: Git.TreeID.make(input.to),
|
||||
}
|
||||
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 })
|
||||
})
|
||||
|
||||
// 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 })
|
||||
.pipe(Effect.mapError((cause) => failure(operation, cause)))
|
||||
return {
|
||||
input: comparison,
|
||||
files,
|
||||
ignored,
|
||||
}
|
||||
})
|
||||
|
||||
const files = Effect.fn("Snapshot.files")(function* (input: CompareInput) {
|
||||
const comparison = yield* compare("files", input)
|
||||
return comparison.files.filter((file) => !comparison.ignored.has(file))
|
||||
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 diff = Effect.fn("Snapshot.diff")(function* (input: DiffInput) {
|
||||
const comparison = yield* compare("diff", input)
|
||||
return yield* git.tree
|
||||
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
|
||||
.diff({
|
||||
...comparison.input,
|
||||
repository: compared.repository,
|
||||
from: compared.from,
|
||||
to: compared.to,
|
||||
context: input.context,
|
||||
paths: (input.paths ?? comparison.files).filter((file) => !comparison.ignored.has(file)),
|
||||
paths: input.paths,
|
||||
})
|
||||
.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) {
|
||||
|
||||
@@ -2,6 +2,7 @@ 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"
|
||||
@@ -25,15 +26,17 @@ const MoveOutput = Schema.Struct({ sessionID: Session.ID, directory: AbsolutePat
|
||||
export const Plugin = {
|
||||
id: "opencode.tools",
|
||||
effect: Effect.fn("OpenCodeTools.Plugin")(function* (ctx: Context) {
|
||||
yield* ctx.session.hook("context", (event) =>
|
||||
const hook = (event: SessionHooks["context"]) =>
|
||||
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." })
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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"
|
||||
@@ -292,7 +293,7 @@ export const Plugin = {
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
yield* ctx.session.hook("context", (event) =>
|
||||
const hook = (event: SessionHooks["context"]) =>
|
||||
Effect.sync(() => {
|
||||
const usePatch =
|
||||
event.model.id.includes("gpt-") && !event.model.id.includes("oss") && !event.model.id.includes("gpt-4")
|
||||
@@ -302,8 +303,10 @@ 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)
|
||||
}),
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ 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"
|
||||
@@ -271,12 +272,14 @@ export const Plugin = {
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
yield* ctx.session.hook("context", (event) =>
|
||||
const hook = (event: SessionHooks["context"]) =>
|
||||
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)
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ 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"
|
||||
@@ -234,7 +235,7 @@ export const Plugin = {
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
yield* ctx.session.hook("context", (event) =>
|
||||
const hook = (event: SessionHooks["context"]) =>
|
||||
Effect.gen(function* () {
|
||||
const tool = event.tools[name]
|
||||
if (!tool) return
|
||||
@@ -258,7 +259,9 @@ 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)
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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"
|
||||
@@ -180,14 +181,16 @@ export const Plugin = {
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
yield* ctx.session.hook("context", (event) =>
|
||||
const hook = (event: SessionHooks["context"]) =>
|
||||
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)
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ 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"
|
||||
@@ -196,6 +197,42 @@ 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(
|
||||
|
||||
@@ -66,7 +66,8 @@ export const registerToolPlugin = <R>(
|
||||
const context = host({
|
||||
...overrides,
|
||||
session: {
|
||||
hook: () => Effect.succeed({ dispose: Effect.void }),
|
||||
...overrides.session,
|
||||
hook: overrides.session?.hook ?? (() => Effect.succeed({ dispose: Effect.void })),
|
||||
},
|
||||
tool: {
|
||||
transform: tools.transform,
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
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 },
|
||||
)
|
||||
})
|
||||
@@ -561,7 +561,9 @@ 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")
|
||||
expect(yield* sessions.messages({ sessionID })).toMatchObject([
|
||||
// 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([
|
||||
{
|
||||
id: background.notificationID,
|
||||
type: "synthetic",
|
||||
@@ -569,7 +571,6 @@ describe("SessionRestart background recovery", () => {
|
||||
metadata: { state: "completed" },
|
||||
},
|
||||
])
|
||||
expect(yield* sessions.messages({ sessionID })).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,11 +9,14 @@ 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"
|
||||
@@ -22,9 +25,20 @@ 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)),
|
||||
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 }
|
||||
}),
|
||||
},
|
||||
}),
|
||||
),
|
||||
deps: [
|
||||
Tool.node,
|
||||
FileAccess.node,
|
||||
@@ -153,6 +167,34 @@ 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,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode/desktop",
|
||||
"private": true,
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"name": "@opencode/http-recorder",
|
||||
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"description": "OpenCode's desktop browser plugin",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -3242,6 +3242,152 @@
|
||||
"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"],
|
||||
@@ -18421,6 +18567,38 @@
|
||||
"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": [
|
||||
{
|
||||
@@ -18452,6 +18630,9 @@
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Session.Message.Compaction"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Session.Message.Idle"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -31,6 +31,7 @@ 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,
|
||||
@@ -523,6 +524,31 @@ 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,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -280,6 +280,18 @@ 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,
|
||||
@@ -291,6 +303,7 @@ export const Info = Schema.Union([
|
||||
Shell,
|
||||
Assistant,
|
||||
Compaction,
|
||||
Idle,
|
||||
]).annotate({ identifier: "Session.Message.Info" })
|
||||
export type Info =
|
||||
| AgentSelected
|
||||
@@ -303,4 +316,5 @@ export type Info =
|
||||
| Shell
|
||||
| Assistant
|
||||
| Compaction
|
||||
| Idle
|
||||
export type Type = Info["type"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"name": "@opencode/sdk",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { SessionNotFoundError, UnknownError } from "@opencode/protocol/errors"
|
||||
import type { Snapshot } from "@opencode/core/snapshot"
|
||||
import { MessageNotFoundError, SessionNotFoundError, UnknownError } from "@opencode/protocol/errors"
|
||||
import { Effect } from "effect"
|
||||
|
||||
export function missingSession(error: Session.NotFoundError) {
|
||||
@@ -9,6 +10,14 @@ 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(
|
||||
@@ -18,3 +27,16 @@ 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 })),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,10 +17,9 @@ import {
|
||||
ServiceUnavailableError,
|
||||
SessionBusyError,
|
||||
SkillNotFoundError,
|
||||
UnknownError,
|
||||
} from "@opencode/protocol/errors"
|
||||
import { AbsolutePath } from "@opencode/core/schema"
|
||||
import { failedMessageDecode, missingSession } from "./session-error"
|
||||
import { failedMessageDecode, failedSnapshot, missingMessage, missingSession } from "./session-error"
|
||||
|
||||
const DefaultSessionsLimit = 50
|
||||
|
||||
@@ -213,15 +212,7 @@ 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",
|
||||
(error) =>
|
||||
new MessageNotFoundError({
|
||||
sessionID: error.sessionID,
|
||||
messageID: error.messageID,
|
||||
message: `Message not found: ${error.messageID}`,
|
||||
}),
|
||||
),
|
||||
Effect.catchTag("Session.MessageNotFoundError", missingMessage),
|
||||
Effect.catchTag(
|
||||
"Session.ForkEmptyError",
|
||||
(error) => new InvalidRequestError({ message: error.message, kind: "empty_session" }),
|
||||
@@ -449,32 +440,14 @@ 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",
|
||||
(error) =>
|
||||
new MessageNotFoundError({
|
||||
sessionID: error.sessionID,
|
||||
messageID: error.messageID,
|
||||
message: `Message not found: ${error.messageID}`,
|
||||
}),
|
||||
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)),
|
||||
),
|
||||
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,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -482,23 +455,13 @@ 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", (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,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
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)),
|
||||
)
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
@@ -528,6 +491,22 @@ 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) {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
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,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"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" }>
|
||||
type Notice = Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" | "idle" }>
|
||||
type Entry = { type: "assistant"; message: SessionMessageAssistant } | { type: "notice"; message: Notice }
|
||||
type Content = SessionMessageAssistant["content"][number]
|
||||
type GroupRow = Extract<TimelineRow.TimelineRow, { _tag: "AssistantPart" }>
|
||||
@@ -763,7 +763,8 @@ 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") return false
|
||||
if (message.type === "user" || message.type === "assistant" || message.type === "shell" || message.type === "idle")
|
||||
return false
|
||||
if (message.type !== "synthetic") return true
|
||||
return !!message.description?.trim() || timelineNoticeRequired(message)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/theme",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/tui",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -285,8 +285,6 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
)
|
||||
renderer.once("destroy", () => shutdown.openUnsafe())
|
||||
yield* Effect.tryPromise(async () => {
|
||||
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
|
||||
void renderer.getPalette({ size: 16 }).catch(() => undefined)
|
||||
const mode = handoff?.mode ?? (await renderer.waitForThemeMode(1000)) ?? "dark"
|
||||
if (renderer.isDestroyed) return
|
||||
|
||||
@@ -484,7 +482,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
const toast = useToast()
|
||||
const updater = useUpdateNotification()
|
||||
const theme = useTheme()
|
||||
const { mode, supports, setMode, locked, lock, unlock } = useThemes()
|
||||
const { mode, supports, setMode, locked, lock, unlock, afterPaint } = useThemes()
|
||||
const data = useData()
|
||||
const location = useLocation()
|
||||
const exit = useExit()
|
||||
@@ -492,6 +490,16 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
const plugins = usePlugin()
|
||||
const clipboard = useClipboard()
|
||||
const terminalEnvironment = useTuiTerminalEnvironment()
|
||||
let paletteTimer: ReturnType<typeof setTimeout> | undefined
|
||||
const afterFrame = () => {
|
||||
// The native writer can still be flushing the frame when FRAME fires. Keep OSC probes behind visible app output.
|
||||
paletteTimer = setTimeout(afterPaint, 50)
|
||||
}
|
||||
onMount(() => renderer.once(CliRenderEvents.FRAME, afterFrame))
|
||||
onCleanup(() => {
|
||||
renderer.off(CliRenderEvents.FRAME, afterFrame)
|
||||
if (paletteTimer) clearTimeout(paletteTimer)
|
||||
})
|
||||
createEffect(() => {
|
||||
if (client.connection.status() !== "connected") return
|
||||
if (route.data.type !== "session") return
|
||||
|
||||
@@ -139,3 +139,7 @@ export function useStorage() {
|
||||
if (!storage) throw new Error("StorageProvider is missing")
|
||||
return storage
|
||||
}
|
||||
|
||||
export function useStorageOptional() {
|
||||
return useContext(Context)
|
||||
}
|
||||
|
||||
@@ -24,10 +24,11 @@ import {
|
||||
import { generateSystem, terminalMode } from "../theme/system"
|
||||
import { discoverThemes } from "../theme/discovery"
|
||||
import { createComponentTheme, createComponentThemeView, type ComponentTheme } from "../theme/component"
|
||||
import { createEffect, createMemo, onCleanup, onMount, type Accessor, type ParentProps } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, onCleanup, onMount, type Accessor, type ParentProps } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useConfig } from "../config"
|
||||
import { useStorageOptional } from "./storage"
|
||||
import { DevTools } from "../devtools"
|
||||
import { configDirectories } from "../util/config-directories"
|
||||
|
||||
@@ -94,7 +95,6 @@ export {
|
||||
const THEME_REFRESH_DELAYS = [250, 1000] as const
|
||||
|
||||
type State = {
|
||||
themes: Record<string, ThemeDocumentSource>
|
||||
mode: "dark" | "light"
|
||||
lock: "dark" | "light" | undefined
|
||||
active: string
|
||||
@@ -116,6 +116,7 @@ type Themes = {
|
||||
unlock(): void
|
||||
setMode(mode?: "dark" | "light", persist?: boolean): boolean
|
||||
set(theme: string): boolean
|
||||
afterPaint(): void
|
||||
onError(handler: ThemeErrorHandler): () => void
|
||||
readonly ready: boolean
|
||||
}
|
||||
@@ -127,14 +128,14 @@ type ThemeContextValue = {
|
||||
}
|
||||
|
||||
const [store, setStore] = createStore<State>({
|
||||
themes: allThemes(),
|
||||
mode: "dark",
|
||||
lock: undefined,
|
||||
active: "opencode",
|
||||
ready: false,
|
||||
})
|
||||
const [themeSources, setThemeSources] = createSignal(allThemes())
|
||||
|
||||
subscribeThemes((themes) => setStore("themes", themes))
|
||||
subscribeThemes(setThemeSources)
|
||||
|
||||
const themeContext = createSimpleContext({
|
||||
name: "Theme",
|
||||
@@ -143,26 +144,24 @@ const themeContext = createSimpleContext({
|
||||
const configState = useConfig()
|
||||
const config = configState.data
|
||||
const themes = props.source
|
||||
const pick = (value: unknown) => {
|
||||
if (value === "dark" || value === "light") return value
|
||||
return
|
||||
}
|
||||
const cache = useStorageOptional()?.store<{ colors?: TerminalColors }>("system-theme", { initial: {} })
|
||||
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const lock = pick(config.theme?.mode)
|
||||
const mode = lock ?? pick(renderer.themeMode) ?? props.mode
|
||||
const lock = config.theme?.mode === "dark" || config.theme?.mode === "light" ? config.theme.mode : undefined
|
||||
const mode = lock ?? renderer.themeMode ?? props.mode
|
||||
draft.mode = mode
|
||||
draft.lock = lock
|
||||
const active = config.theme?.name ?? "opencode"
|
||||
draft.active = typeof active === "string" ? active : "opencode"
|
||||
draft.active = config.theme?.name ?? "opencode"
|
||||
draft.ready = false
|
||||
}),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const theme = config.theme?.name
|
||||
if (theme) setStore("active", theme)
|
||||
if (!theme) return
|
||||
setStore("active", theme)
|
||||
if (theme === "system") refreshPalette()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
@@ -184,70 +183,69 @@ const themeContext = createSimpleContext({
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const systemTheme = resolveSystemTheme(store.mode)
|
||||
void Promise.allSettled([
|
||||
store.active === "system" ? systemTheme : Promise.resolve(),
|
||||
syncCustomThemes(),
|
||||
]).finally(() => {
|
||||
valuesV2()
|
||||
// Terminal palette queries serialize with frame output. First paint uses the cached palette or built-in fallback.
|
||||
void syncCustomThemes().finally(() => {
|
||||
tokens()
|
||||
setStore("ready", true)
|
||||
})
|
||||
})
|
||||
|
||||
let systemThemeSignature: string | undefined
|
||||
let systemThemeMode: "dark" | "light" | undefined
|
||||
let hasResolvedSystemTheme = false
|
||||
function resolveSystemTheme(mode: "dark" | "light" = store.mode) {
|
||||
return renderer
|
||||
const cachedPalette = cache?.[0].colors
|
||||
let palette = usablePalette(cachedPalette) ? cachedPalette : undefined
|
||||
const applyPalette = () => {
|
||||
if (palette) setSystemTheme(generateSystem(palette, store.mode))
|
||||
}
|
||||
if (palette) {
|
||||
const mode = store.lock ?? terminalMode(palette) ?? store.mode
|
||||
if (store.mode !== mode) setStore("mode", mode)
|
||||
applyPalette()
|
||||
} else setSystemTheme(undefined)
|
||||
|
||||
let canProbe = false
|
||||
let probing = false
|
||||
let queued = false
|
||||
let disposed = false
|
||||
function refreshPalette() {
|
||||
if (store.active !== "system") return
|
||||
queued = true
|
||||
if (!canProbe || probing || disposed) return
|
||||
|
||||
queued = false
|
||||
probing = true
|
||||
// Clearing does not cancel a query already owned by the renderer. Share it, then run one fresh query.
|
||||
const retry = renderer.paletteDetectionStatus === "detecting"
|
||||
renderer.clearPaletteCache()
|
||||
void renderer
|
||||
.getPalette({ size: 16 })
|
||||
.then((colors: TerminalColors) => {
|
||||
if (!colors.palette[0]) {
|
||||
if (hasResolvedSystemTheme) return
|
||||
setSystemTheme(undefined)
|
||||
if (store.active === "system") setStore("active", "opencode")
|
||||
return
|
||||
}
|
||||
const next = store.lock ?? terminalMode(colors) ?? mode
|
||||
if (store.mode !== next) setStore("mode", next)
|
||||
const signature = JSON.stringify(colors)
|
||||
hasResolvedSystemTheme = true
|
||||
if (store.themes.system && systemThemeSignature === signature && systemThemeMode === next) return
|
||||
systemThemeSignature = signature
|
||||
systemThemeMode = next
|
||||
setSystemTheme(generateSystem(colors, next))
|
||||
.then((colors) => {
|
||||
if (disposed || !usablePalette(colors)) return
|
||||
palette = colors
|
||||
const mode = store.lock ?? terminalMode(colors) ?? store.mode
|
||||
if (store.mode !== mode) setStore("mode", mode)
|
||||
applyPalette()
|
||||
void cache?.[1]((draft) => {
|
||||
draft.colors = colors
|
||||
}).catch(() => {})
|
||||
})
|
||||
.catch(() => {
|
||||
if (hasResolvedSystemTheme) return
|
||||
setSystemTheme(undefined)
|
||||
if (store.active === "system") setStore("active", "opencode")
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
probing = false
|
||||
if (disposed || (!retry && !queued)) return
|
||||
refreshPalette()
|
||||
})
|
||||
}
|
||||
|
||||
let systemRefreshRunning = false
|
||||
let systemRefreshQueued = false
|
||||
let systemRefreshMode = store.mode
|
||||
function refreshSystemTheme(mode: "dark" | "light" = store.mode) {
|
||||
systemRefreshMode = mode
|
||||
if (systemRefreshRunning) {
|
||||
systemRefreshQueued = true
|
||||
return
|
||||
}
|
||||
|
||||
systemRefreshRunning = true
|
||||
const retry = renderer.paletteDetectionStatus === "detecting"
|
||||
renderer.clearPaletteCache()
|
||||
void resolveSystemTheme(mode).finally(() => {
|
||||
systemRefreshRunning = false
|
||||
if (!retry && !systemRefreshQueued) return
|
||||
systemRefreshQueued = false
|
||||
refreshSystemTheme(systemRefreshMode)
|
||||
})
|
||||
function afterPaint() {
|
||||
canProbe = true
|
||||
refreshPalette()
|
||||
}
|
||||
|
||||
function apply(mode: "dark" | "light") {
|
||||
if (store.mode === mode) return
|
||||
setStore("mode", mode)
|
||||
refreshSystemTheme(mode)
|
||||
if (store.mode !== mode) {
|
||||
setStore("mode", mode)
|
||||
applyPalette()
|
||||
}
|
||||
refreshPalette()
|
||||
}
|
||||
|
||||
function pin(mode: "dark" | "light" = store.mode, persist = true) {
|
||||
@@ -263,7 +261,7 @@ const themeContext = createSimpleContext({
|
||||
|
||||
function free(persist = true) {
|
||||
setStore("lock", undefined)
|
||||
refreshSystemTheme(renderer.themeMode ?? store.mode)
|
||||
apply(renderer.themeMode ?? store.mode)
|
||||
if (!persist) return
|
||||
void configState
|
||||
.update((draft) => {
|
||||
@@ -272,33 +270,34 @@ const themeContext = createSimpleContext({
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
const handle = (mode: "dark" | "light") => {
|
||||
const handleMode = (mode: "dark" | "light") => {
|
||||
if (store.lock) return
|
||||
apply(mode)
|
||||
}
|
||||
renderer.on(CliRenderEvents.THEME_MODE, handle)
|
||||
renderer.on(CliRenderEvents.THEME_MODE, handleMode)
|
||||
|
||||
const handleThemeNotification = (sequence: string) => {
|
||||
if (sequence !== "\x1b[?997;1n" && sequence !== "\x1b[?997;2n") return false
|
||||
queueMicrotask(() => refreshSystemTheme())
|
||||
queueMicrotask(refreshPalette)
|
||||
return false
|
||||
}
|
||||
renderer.prependInputHandler(handleThemeNotification)
|
||||
|
||||
let themeRefreshTimeouts: ReturnType<typeof setTimeout>[] = []
|
||||
const refresh = () => {
|
||||
const refreshThemes = () => {
|
||||
for (const timeout of themeRefreshTimeouts) clearTimeout(timeout)
|
||||
themeRefreshTimeouts = THEME_REFRESH_DELAYS.map((delay) =>
|
||||
setTimeout(() => {
|
||||
refreshSystemTheme()
|
||||
refreshPalette()
|
||||
if (delay === THEME_REFRESH_DELAYS[THEME_REFRESH_DELAYS.length - 1]) void syncCustomThemes()
|
||||
}, delay),
|
||||
)
|
||||
}
|
||||
const unsubscribeRefresh = themes.subscribeRefresh?.(refresh)
|
||||
const unsubscribeRefresh = themes.subscribeRefresh?.(refreshThemes)
|
||||
|
||||
onCleanup(() => {
|
||||
renderer.off(CliRenderEvents.THEME_MODE, handle)
|
||||
disposed = true
|
||||
renderer.off(CliRenderEvents.THEME_MODE, handleMode)
|
||||
renderer.removeInputHandler(handleThemeNotification)
|
||||
unsubscribeRefresh?.()
|
||||
for (const timeout of themeRefreshTimeouts) clearTimeout(timeout)
|
||||
@@ -307,29 +306,30 @@ const themeContext = createSimpleContext({
|
||||
|
||||
const initStarted = performance.now()
|
||||
const selected = createMemo(() => {
|
||||
const name = store.themes[store.active] ? store.active : "opencode"
|
||||
const sources = themeSources()
|
||||
const name = sources[store.active] ? store.active : "opencode"
|
||||
try {
|
||||
return loadTheme(store.themes[name], name, store.mode)
|
||||
return loadTheme(sources[name], name, store.mode)
|
||||
} catch (error) {
|
||||
if (name === "opencode") throw error
|
||||
themeErrors.emit(name, error)
|
||||
setStore("active", "opencode")
|
||||
return loadTheme(store.themes.opencode, "opencode", store.mode)
|
||||
return loadTheme(sources.opencode, "opencode", store.mode)
|
||||
}
|
||||
})
|
||||
const modes = () => selected().modes
|
||||
const mode = () => selected().mode
|
||||
const valuesV2 = () => selected().theme
|
||||
valuesV2()
|
||||
const tokens = () => selected().theme
|
||||
tokens()
|
||||
themePerformance.set("Init", `${(performance.now() - initStarted).toFixed(2)} ms`)
|
||||
const current = createComponentTheme(valuesV2, mode)
|
||||
const current = createComponentTheme(tokens, mode)
|
||||
|
||||
createEffect(() => renderer.setBackgroundColor(valuesV2().background.default))
|
||||
createEffect(() => renderer.setBackgroundColor(tokens().background.default))
|
||||
|
||||
const currentSyntax = createSyntaxStyleMemo(() => generateSyntax(valuesV2(), mode()))
|
||||
const currentSyntax = createSyntaxStyleMemo(() => generateSyntax(tokens(), mode()))
|
||||
const service: Themes = {
|
||||
current,
|
||||
currentTokens: valuesV2,
|
||||
currentTokens: tokens,
|
||||
currentSyntax,
|
||||
get selected() {
|
||||
return store.active
|
||||
@@ -350,6 +350,7 @@ const themeContext = createSimpleContext({
|
||||
set(theme: string) {
|
||||
if (!hasTheme(theme)) return false
|
||||
setStore("active", theme)
|
||||
if (theme === "system") refreshPalette()
|
||||
void configState
|
||||
.update((draft) => {
|
||||
draft.theme = { ...draft.theme, name: theme }
|
||||
@@ -357,6 +358,7 @@ const themeContext = createSimpleContext({
|
||||
.catch(() => {})
|
||||
return true
|
||||
},
|
||||
afterPaint,
|
||||
onError: themeErrors.onError,
|
||||
get ready() {
|
||||
return store.ready
|
||||
@@ -383,6 +385,12 @@ export function useTheme(context?: ContextName) {
|
||||
}
|
||||
export const ThemeProvider = themeContext.provider
|
||||
|
||||
function usablePalette(colors: TerminalColors | undefined): colors is TerminalColors {
|
||||
return Boolean(
|
||||
colors && (colors.defaultBackground ?? colors.palette[0]) && (colors.defaultForeground ?? colors.palette[7]),
|
||||
)
|
||||
}
|
||||
|
||||
/** Switches context without remounting children; undefined inherits the enclosing view. */
|
||||
export function ThemeContextProvider(props: ParentProps<{ context: ContextName | undefined }>) {
|
||||
const value = themeContext.use()
|
||||
|
||||
@@ -289,6 +289,7 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S
|
||||
...messages.filter(isInput),
|
||||
].reduce<ProjectionEntry[]>((rows, message) => {
|
||||
if (message.type !== "assistant") {
|
||||
if (message.type === "idle") return rows
|
||||
if (message.type === "synthetic" && !message.description?.trim()) return rows
|
||||
if (message.type === "compaction" && message.status === "completed" && usage) usage.previousTurnCache = undefined
|
||||
rows.push({ entry: { type: "message", messageID: message.id }, closesPrevious: !pending.has(message.id) })
|
||||
|
||||
@@ -15,17 +15,16 @@ const listeners = new Set<(themes: Record<string, ThemeDocumentSource>) => void>
|
||||
const parsed = new WeakMap<object, ThemeDocument>()
|
||||
const decodeThemeDocument = Schema.decodeUnknownSync(ThemeDocument, { reportInput: true })
|
||||
|
||||
function listThemes() {
|
||||
function listThemes(): Record<string, ThemeDocumentSource> {
|
||||
// Priority: defaults < plugin installs < custom files < generated system.
|
||||
const themes: Record<string, ThemeDocumentSource> = {
|
||||
...DEFAULT_THEMES,
|
||||
...pluginThemes,
|
||||
...customThemes,
|
||||
}
|
||||
if (!systemTheme) return themes
|
||||
return {
|
||||
...themes,
|
||||
system: systemTheme,
|
||||
system: systemTheme ?? themes.system ?? themes.opencode,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user