mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-10 19:06:24 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd0cc9695d |
@@ -18,6 +18,7 @@ const WebSocketResponseCreate = Schema.StructWithRest(Schema.Struct({ type: Sche
|
||||
])
|
||||
const decodeMessage = ProviderShared.validateWith(Schema.decodeUnknownEffect(WebSocketResponseCreate))
|
||||
const encodeMessage = Schema.encodeSync(Schema.fromJsonString(WebSocketResponseCreate))
|
||||
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
|
||||
|
||||
export interface Options {
|
||||
readonly id: string
|
||||
@@ -26,7 +27,6 @@ export interface Options {
|
||||
readonly enabled?: (url: string) => boolean
|
||||
readonly url?: (url: string) => string
|
||||
readonly headers?: (headers: Headers.Headers) => Headers.Headers
|
||||
readonly continuation?: OpenResponsesContinuation.Shape
|
||||
}
|
||||
|
||||
export interface Prepared {
|
||||
@@ -60,7 +60,7 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
|
||||
}),
|
||||
observe: (_create, frame) =>
|
||||
Effect.gen(function* () {
|
||||
const event = yield* OpenResponses.decodeChannelEvent(frame).pipe(
|
||||
const event = yield* decodeEvent(frame).pipe(
|
||||
Effect.mapError((cause) =>
|
||||
ProviderShared.eventError(options.id, `Invalid ${options.name} WebSocket event`, frame, cause),
|
||||
),
|
||||
@@ -163,7 +163,6 @@ export const transport = <Body>(options: Options): Transport<Body, Prepared, str
|
||||
request: create.request,
|
||||
message: create.message,
|
||||
base,
|
||||
continuation: options.continuation,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import { OpenResponses } from "./open-responses.js"
|
||||
|
||||
const PROTOCOL = "open-responses.websocket.v1"
|
||||
const VERSION = 1
|
||||
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
|
||||
|
||||
interface CheckpointValue {
|
||||
readonly version: typeof VERSION
|
||||
@@ -14,19 +15,12 @@ interface CheckpointValue {
|
||||
readonly output: ReadonlyArray<unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Fields to send next to `previous_response_id` on an incremental step, or undefined to send the step in full.
|
||||
* Whether omitted fields carry over from the continued response is provider behavior the route must know.
|
||||
*/
|
||||
export type Shape = (request: Readonly<Record<string, unknown>>) => Readonly<Record<string, unknown>> | undefined
|
||||
|
||||
export interface DriverInput {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly request: Readonly<Record<string, unknown>>
|
||||
readonly message: string
|
||||
readonly base: WebSocketChannelDriver
|
||||
readonly continuation?: Shape
|
||||
}
|
||||
|
||||
const checkpointValue = (checkpoint: ChannelCheckpoint | undefined): CheckpointValue | undefined => {
|
||||
@@ -133,26 +127,22 @@ const rejected = (
|
||||
|
||||
export const driver = (input: DriverInput): WebSocketChannelDriver => {
|
||||
const { previous_response_id: _previousResponseID, ...request } = input.request
|
||||
const shape = input.continuation ?? ((fields: Readonly<Record<string, unknown>>) => fields)
|
||||
let output: OpenResponses.StreamItem[] = []
|
||||
return {
|
||||
create: (checkpoint) =>
|
||||
Effect.sync(() => {
|
||||
output = []
|
||||
const previous = checkpointValue(checkpoint)
|
||||
// Ask the route first: diffing the whole history is wasted when it declines the continuation.
|
||||
const fields = previous ? shape(request) : undefined
|
||||
const delta = previous && fields ? incremental(request, previous) : undefined
|
||||
if (!previous || !fields || !delta)
|
||||
return { message: ProviderShared.encodeJson(request), mode: "full" as const }
|
||||
const delta = previous ? incremental(request, previous) : undefined
|
||||
if (!previous || !delta) return { message: ProviderShared.encodeJson(request), mode: "full" as const }
|
||||
return {
|
||||
message: ProviderShared.encodeJson({ ...fields, input: delta, previous_response_id: previous.responseID }),
|
||||
message: ProviderShared.encodeJson({ ...request, input: delta, previous_response_id: previous.responseID }),
|
||||
mode: "incremental" as const,
|
||||
}
|
||||
}),
|
||||
observe: (create, frame) =>
|
||||
Effect.gen(function* () {
|
||||
const event = yield* OpenResponses.decodeChannelEvent(frame).pipe(
|
||||
const event = yield* decodeEvent(frame).pipe(
|
||||
Effect.mapError((cause) =>
|
||||
ProviderShared.eventError(input.id, `Invalid ${input.name} WebSocket event`, frame, cause),
|
||||
),
|
||||
@@ -205,4 +195,4 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
|
||||
}
|
||||
}
|
||||
|
||||
export * as OpenResponsesContinuation from "./open-responses-continuation.js"
|
||||
export const OpenResponsesContinuation = { driver } as const
|
||||
|
||||
@@ -405,24 +405,6 @@ export const Event = Schema.StructWithRest(
|
||||
export type Event = Schema.Schema.Type<typeof Event>
|
||||
export type NormalizedEvent = Event & { readonly item?: OutputItem | null }
|
||||
|
||||
const decodeEventValue = Schema.decodeUnknownEffect(Event)
|
||||
const decodeFrame = Schema.decodeUnknownEffect(ProviderShared.Json)
|
||||
|
||||
/**
|
||||
* Decodes one WebSocket frame. xAI answers a rejected `response.create` with `{ "error": { "message", "type" } }` and no
|
||||
* event type; that envelope reads as an error event so the failure classifies instead of failing decoding.
|
||||
*/
|
||||
export const decodeChannelEvent = (frame: string) =>
|
||||
decodeFrame(frame).pipe(
|
||||
Effect.flatMap((value) =>
|
||||
decodeEventValue(
|
||||
ProviderShared.isRecord(value) && value.type === undefined && ProviderShared.isRecord(value.error)
|
||||
? { ...value, type: "error" }
|
||||
: value,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
export interface ProviderAdapter {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
|
||||
@@ -41,10 +41,6 @@ const responsesRoute = Route.make({
|
||||
id: "openai-responses",
|
||||
name: "xAI Responses",
|
||||
rotateAfterMs: RESPONSES_WEBSOCKET_ROTATE_AFTER_MS,
|
||||
// xAI continues a chain only from stored responses: with `store: false` (the route default) `previous_response_id`
|
||||
// fails with "Response with id=… not found", so those steps are sent in full over the reused connection. It also
|
||||
// rejects `instructions` next to `previous_response_id` and keeps the instructions of the response it continues.
|
||||
continuation: ({ instructions: _instructions, ...request }) => (request.store === false ? undefined : request),
|
||||
}),
|
||||
defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } },
|
||||
})
|
||||
|
||||
@@ -90,11 +90,7 @@ const classifyingChannelDriver = (message: string): WebSocketChannelDriver => {
|
||||
}
|
||||
}
|
||||
|
||||
const continuationDriver = (
|
||||
request: Readonly<Record<string, unknown>>,
|
||||
base = baseChannelDriver,
|
||||
continuation?: OpenResponsesContinuation.Shape,
|
||||
) => {
|
||||
const continuationDriver = (request: Readonly<Record<string, unknown>>, base = baseChannelDriver) => {
|
||||
const message = ProviderShared.encodeJson(request)
|
||||
return OpenResponsesContinuation.driver({
|
||||
id: "openai-responses",
|
||||
@@ -102,7 +98,6 @@ const continuationDriver = (
|
||||
request,
|
||||
message,
|
||||
base: base(message),
|
||||
continuation,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -926,58 +921,6 @@ describe("OpenAI Responses route", () => {
|
||||
type: "provider-failure",
|
||||
error: { reason: { _tag: "InvalidRequest", classification: "context-overflow" } },
|
||||
})
|
||||
|
||||
// A retryable failure stays one: the runner retries it, and the transport has already dropped the
|
||||
// checkpoint, so that retry is a full send. xAI reports every rejection this way.
|
||||
const internal = ProviderShared.encodeJson({
|
||||
type: "error",
|
||||
error: { type: "api_error", message: "gRPC error: Response with id=resp_1 not found" },
|
||||
})
|
||||
expect(yield* second.observe(yield* second.create(saved), internal)).toMatchObject({
|
||||
type: "provider-failure",
|
||||
error: { reason: { _tag: "ProviderInternal" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("shapes the incremental send with the route continuation", () =>
|
||||
Effect.gen(function* () {
|
||||
const firstRequest = {
|
||||
type: "response.create",
|
||||
model: "grok-4.6",
|
||||
store: true,
|
||||
instructions: "You are terse.",
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "First" }] }],
|
||||
}
|
||||
const secondRequest = {
|
||||
...firstRequest,
|
||||
input: [...firstRequest.input, { role: "user", content: [{ type: "input_text", text: "Second" }] }],
|
||||
}
|
||||
const saved = checkpoint(
|
||||
yield* continuationDriver(firstRequest).observe(
|
||||
yield* continuationDriver(firstRequest).create(undefined),
|
||||
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
|
||||
),
|
||||
)
|
||||
|
||||
const trimmed = yield* continuationDriver(
|
||||
secondRequest,
|
||||
baseChannelDriver,
|
||||
({ instructions: _, ...rest }) => rest,
|
||||
).create(saved)
|
||||
expect(trimmed.mode).toBe("incremental")
|
||||
expect(JSON.parse(trimmed.message)).toEqual({
|
||||
type: "response.create",
|
||||
model: "grok-4.6",
|
||||
store: true,
|
||||
previous_response_id: "resp_1",
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "Second" }] }],
|
||||
})
|
||||
|
||||
// Declining the continuation sends the step in full and never sends a previous_response_id.
|
||||
const declined = yield* continuationDriver(secondRequest, baseChannelDriver, () => undefined).create(saved)
|
||||
expect(declined.mode).toBe("full")
|
||||
expect(JSON.parse(declined.message)).toEqual(secondRequest)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMEvent, Message } from "../../src/index.js"
|
||||
import { XAI } from "../../src/providers.js"
|
||||
import { OpenResponses } from "../../src/protocols/open-responses.js"
|
||||
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
|
||||
import * as ProviderShared from "../../src/protocols/shared.js"
|
||||
import { XAIResponses } from "../../src/protocols/xai-responses.js"
|
||||
import {
|
||||
LLMClient,
|
||||
RequestExecutor,
|
||||
WebSocketTransport,
|
||||
type ChannelCheckpoint,
|
||||
type WebSocketChannelDriver,
|
||||
} from "../../src/route.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { fixedResponse } from "../lib/http.js"
|
||||
@@ -20,35 +13,6 @@ import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
const model = XAI.configure({ apiKey: "test", baseURL: "https://api.x.ai/v1" }).responses("grok-4.6")
|
||||
|
||||
/** Runs a request through the WebSocket transport and hands back its channel driver; the HTTP fallback answers. */
|
||||
const channelDriver = (request: ReturnType<typeof LLM.request>) =>
|
||||
Effect.gen(function* () {
|
||||
let driver: WebSocketChannelDriver | undefined
|
||||
yield* LLMClient.generate(request, {
|
||||
webSocket: {
|
||||
execute: (exchange) =>
|
||||
Effect.sync(() => {
|
||||
driver = exchange.driver
|
||||
return { frames: exchange.fallback(), complete: Effect.void }
|
||||
}),
|
||||
},
|
||||
}).pipe(Effect.provide(fixedResponse(sseEvents({ type: "response.completed", response: { id: "http" } }))))
|
||||
if (!driver) throw new Error("Expected a WebSocket channel driver")
|
||||
return driver
|
||||
})
|
||||
|
||||
const completed = (driver: WebSocketChannelDriver, id: string) =>
|
||||
Effect.gen(function* () {
|
||||
const create = yield* driver.create(undefined)
|
||||
yield* driver.observe(create, ProviderShared.encodeJson({ type: "response.created", response: { id } }))
|
||||
const observation = yield* driver.observe(
|
||||
create,
|
||||
ProviderShared.encodeJson({ type: "response.completed", response: { id } }),
|
||||
)
|
||||
if (observation.type !== "completed" || !observation.checkpoint) throw new Error("Expected a checkpoint")
|
||||
return observation.checkpoint
|
||||
})
|
||||
|
||||
describe("xAI Responses route", () => {
|
||||
it.effect("composes the Open Responses baseline with xAI extensions", () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -198,78 +162,6 @@ describe("xAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies xAI's untyped WebSocket error envelope", () =>
|
||||
Effect.gen(function* () {
|
||||
// xAI answers a rejected response.create with an error envelope that carries no event type.
|
||||
const envelope = ProviderShared.encodeJson({
|
||||
error: {
|
||||
message:
|
||||
'Request validation error: {"code":"400","error":"Argument not supported: instructions and previous_response_id together"}',
|
||||
type: "api_error",
|
||||
},
|
||||
})
|
||||
const webSocket = WebSocketTransport.makeDirect({
|
||||
open: () =>
|
||||
Effect.succeed({ sendText: () => Effect.void, messages: Stream.make(envelope), close: Effect.void }),
|
||||
})
|
||||
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" }), { webSocket }).pipe(
|
||||
Effect.provide(
|
||||
LLMClient.layer.pipe(
|
||||
Layer.provide(
|
||||
Layer.succeed(
|
||||
RequestExecutor.Service,
|
||||
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason._tag).toBe("ProviderInternal")
|
||||
expect(error.message).toContain("Argument not supported: instructions and previous_response_id together")
|
||||
expect(error.reason.body).toBe(envelope)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues stored responses without instructions and sends unstored steps in full", () =>
|
||||
Effect.gen(function* () {
|
||||
const step = (store: boolean, ...prompts: string[]) =>
|
||||
LLM.request({
|
||||
model,
|
||||
system: "You are terse.",
|
||||
messages: prompts.map((prompt) => Message.user(prompt)),
|
||||
providerOptions: { store },
|
||||
})
|
||||
const send = (store: boolean, checkpoint: ChannelCheckpoint) =>
|
||||
channelDriver(step(store, "First", "Second")).pipe(Effect.flatMap((driver) => driver.create(checkpoint)))
|
||||
|
||||
const stored = yield* send(true, yield* completed(yield* channelDriver(step(true, "First")), "resp_1"))
|
||||
expect(stored.mode).toBe("incremental")
|
||||
expect(JSON.parse(stored.message)).toEqual({
|
||||
type: "response.create",
|
||||
model: "grok-4.6",
|
||||
store: true,
|
||||
include: ["reasoning.encrypted_content"],
|
||||
previous_response_id: "resp_1",
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "Second" }] }],
|
||||
})
|
||||
|
||||
// The connection cache only serves stored responses, so the default store: false never chains.
|
||||
const unstored = yield* send(false, yield* completed(yield* channelDriver(step(false, "First")), "resp_1"))
|
||||
expect(unstored.mode).toBe("full")
|
||||
expect(JSON.parse(unstored.message)).toMatchObject({
|
||||
instructions: "You are terse.",
|
||||
store: false,
|
||||
input: [
|
||||
{ role: "user", content: [{ type: "input_text", text: "First" }] },
|
||||
{ role: "user", content: [{ type: "input_text", text: "Second" }] },
|
||||
],
|
||||
})
|
||||
expect(JSON.parse(unstored.message).previous_response_id).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses xAI hosted tool items", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = { type: "x_search_call", id: "x_search_1", status: "completed", action: { query: "news" } }
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { sessionID, setupTimeline, userMessage } from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("keeps a submitted prompt in place while its optimistic rows are measured", async ({ page }) => {
|
||||
await setupTimeline(page, { messages: [userMessage()], seedHistory: true })
|
||||
const release = Promise.withResolvers<void>()
|
||||
await page.route(`**/api/session/${sessionID}/prompt`, async (route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
await release.promise
|
||||
return route.fallback()
|
||||
})
|
||||
|
||||
const editor = page.locator('[data-component="composer"]').getByRole("textbox")
|
||||
await expect(editor).toBeEditable()
|
||||
await editor.fill("Observe optimistic prompt spacing.")
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.locator("[data-timeline-virtual-content]").evaluate((element) => {
|
||||
const root = element.parentElement!
|
||||
return root.scrollHeight - root.clientHeight - root.scrollTop
|
||||
}),
|
||||
)
|
||||
.toBe(0)
|
||||
|
||||
const observation = await page.evaluateHandle(() => {
|
||||
const frames: { prompt?: number; working: boolean }[] = []
|
||||
let frame = 0
|
||||
const sample = () => {
|
||||
const prompt = [...document.querySelectorAll<HTMLElement>('[data-timeline-row="UserMessage"]')].find((row) =>
|
||||
row.textContent?.includes("Observe optimistic prompt spacing."),
|
||||
)
|
||||
frames.push({
|
||||
...(prompt ? { prompt: prompt.getBoundingClientRect().y } : {}),
|
||||
working: !!document.querySelector('[data-component="session-working"]'),
|
||||
})
|
||||
frame = requestAnimationFrame(sample)
|
||||
}
|
||||
frame = requestAnimationFrame(sample)
|
||||
return {
|
||||
stop: () => {
|
||||
cancelAnimationFrame(frame)
|
||||
return frames
|
||||
},
|
||||
}
|
||||
})
|
||||
const requested = page.waitForRequest(
|
||||
(request) => request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/prompt`,
|
||||
)
|
||||
try {
|
||||
await editor.press("Enter")
|
||||
await requested
|
||||
const prompt = page
|
||||
.locator('[data-timeline-row="UserMessage"]')
|
||||
.filter({ hasText: "Observe optimistic prompt spacing." })
|
||||
await expect(prompt).toBeInViewport()
|
||||
await expect(page.locator('[data-component="session-working"]')).toBeVisible()
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.locator("[data-timeline-virtual-content]").evaluate((element) => {
|
||||
const root = element.parentElement!
|
||||
return root.scrollHeight - root.clientHeight - root.scrollTop
|
||||
}),
|
||||
)
|
||||
.toBe(0)
|
||||
const frames = await observation.evaluate((value) => value.stop())
|
||||
expect(frames.some((frame) => frame.working && frame.prompt === undefined)).toBe(false)
|
||||
const positions = frames.flatMap((frame) => (frame.prompt === undefined ? [] : [frame.prompt]))
|
||||
expect(positions.length).toBeGreaterThan(0)
|
||||
expect(new Set(positions).size).toBe(1)
|
||||
} finally {
|
||||
release.resolve()
|
||||
await observation.dispose()
|
||||
}
|
||||
})
|
||||
@@ -122,15 +122,7 @@ test("renders compaction progress, summary, and outcome in order", async ({ page
|
||||
)
|
||||
await expect(compaction.getByRole("heading", { name: "Checkpoint" })).toBeVisible()
|
||||
await expect(compaction).toContainText("Streamed implementation details.")
|
||||
const running = compaction.getByRole("status").getByLabel("Compacting", { exact: true })
|
||||
await expect(running).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const summary = await compaction.locator('[data-component="text-part"]').boundingBox()
|
||||
const status = await running.boundingBox()
|
||||
return !!summary && !!status && status.y >= summary.y + summary.height
|
||||
})
|
||||
.toBe(true)
|
||||
await expect(compaction.getByRole("status").getByLabel("Compacting", { exact: true })).toBeVisible()
|
||||
await expect(compaction.getByText("Session compacted", { exact: true })).toHaveCount(0)
|
||||
|
||||
await timeline.send(
|
||||
|
||||
@@ -88,12 +88,8 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
if (value.mode === "normal" && !command) {
|
||||
session.handoff?.set(handoffMessage(value))
|
||||
const optimisticBusy = !input.adapter.working()
|
||||
if (optimisticBusy && input.adapter.kind === "new-session")
|
||||
session.data.session.setStatus(session.id, "running")
|
||||
const sending = sendPrompt(session, value, input.adapter.controls().model.selection.trackSessionCommit, () => {
|
||||
if (optimisticBusy && input.adapter.kind === "active-session")
|
||||
session.data.session.setStatus(session.id, "running")
|
||||
}).then(
|
||||
if (optimisticBusy) session.data.session.setStatus(session.id, "running")
|
||||
const sending = sendPrompt(session, value, input.adapter.controls().model.selection.trackSessionCommit).then(
|
||||
() => ({ ok: true as const }),
|
||||
(error) => ({ ok: false as const, error }),
|
||||
)
|
||||
@@ -126,9 +122,15 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
|
||||
if (command) {
|
||||
clearSubmission(input, submission)
|
||||
void sendCommand(session, value, command, input.adapter.controls().model.selection.trackSessionCommit).catch(
|
||||
(error) => failSubmission(input, session, "command", error, restore, value.id),
|
||||
)
|
||||
// Commands always steer: the server applies a command's configured
|
||||
// agent and model immediately at admission, so queueing one would
|
||||
// reconfigure the turn it is supposed to wait behind.
|
||||
void sendCommand(
|
||||
session,
|
||||
{ ...value, delivery: "steer" },
|
||||
command,
|
||||
input.adapter.controls().model.selection.trackSessionCommit,
|
||||
).catch((error) => failSubmission(input, session, "command", error, restore, value.id))
|
||||
return
|
||||
}
|
||||
} finally {
|
||||
@@ -320,8 +322,7 @@ async function sendCommand(
|
||||
track?: ModelSelection["trackSessionCommit"],
|
||||
) {
|
||||
const request = await buildSubmissionRequest(session, value)
|
||||
// Like queued prompts, queued commands must not apply the composer's selection to active work.
|
||||
if (value.delivery === "steer") await applySelection(session, value.selection, track)
|
||||
await applySelection(session, value.selection, track)
|
||||
await session.api.command({
|
||||
sessionID: session.id,
|
||||
command: command.command,
|
||||
@@ -358,8 +359,7 @@ async function applySelection(
|
||||
async function sendPrompt(
|
||||
session: ComposerSession,
|
||||
value: ComposerSubmission,
|
||||
track: ModelSelection["trackSessionCommit"] | undefined,
|
||||
onAdmit: () => void,
|
||||
track?: ModelSelection["trackSessionCommit"],
|
||||
) {
|
||||
const request = await buildSubmissionRequest(session, value)
|
||||
// Switching agent or model reconfigures the session immediately, and with it
|
||||
@@ -389,9 +389,7 @@ async function sendPrompt(
|
||||
},
|
||||
},
|
||||
}
|
||||
const sending = session.data.session.prompt(admission).catch(() => session.data.session.prompt(admission))
|
||||
onAdmit()
|
||||
await sending
|
||||
await session.data.session.prompt(admission).catch(() => session.data.session.prompt(admission))
|
||||
}
|
||||
|
||||
async function buildSubmissionRequest(session: ComposerSession, value: ComposerSubmission) {
|
||||
|
||||
@@ -515,19 +515,6 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
<div
|
||||
ref={(value) => {
|
||||
element = value
|
||||
if (row()._tag !== "UserMessage" || !addedKeys.has(rowProps.rowKey) || !input.pinned() || coldPending)
|
||||
return
|
||||
// The optimistic row can paint before ResizeObserver corrects the tail estimates.
|
||||
// Measure the mounted tail and pin it in this render's microtask instead.
|
||||
queueMicrotask(() => {
|
||||
if (!input.pinned() || !virtualContent?.isConnected) return
|
||||
virtualizer.elementsCache.forEach((item) => {
|
||||
if (item.isConnected) virtualizer.resizeItem(virtualizer.indexFromElement(item), item.offsetHeight)
|
||||
})
|
||||
virtualizer.resizeItem(item().index, element.offsetHeight)
|
||||
virtualContent.style.height = `${virtualizer.getTotalSize()}px`
|
||||
virtualizer.scrollToEnd()
|
||||
})
|
||||
}}
|
||||
data-index={item().index}
|
||||
style={{ "min-height": ready() ? undefined : `${initialItem.size}px` }}
|
||||
|
||||
@@ -44,21 +44,18 @@ ultimate source of truth.
|
||||
|
||||
## Bindings and destructuring
|
||||
|
||||
- [x] `const`, `let`, and `var` declarations.
|
||||
- [x] `const`, `let`, and accepted `var` declarations.
|
||||
- [x] Object and array destructuring in declarations, parameters, assignment expressions, and `for...of` bindings.
|
||||
- [x] Nested patterns, defaults, elisions, and rest elements.
|
||||
- [x] Assignment to identifiers, plain-object fields, non-negative integer array indexes, and writable URL
|
||||
fields.
|
||||
- [x] Direct function declarations are hoisted in program and block statement lists.
|
||||
- [x] Parameter defaults observe a temporal dead zone for later parameters.
|
||||
- [x] `var` is function-scoped and hoisted: names declared anywhere in a function or program body, including loop
|
||||
heads, blocks, `switch` cases, and `try`/`catch`, read as `undefined` before their statement runs; redeclaration
|
||||
assigns the one binding; a same-named parameter keeps its argument; closures in parameter defaults see outer
|
||||
names rather than body `var`s.
|
||||
- [ ] JavaScript-correct function scoping, hoisting, and redeclaration for accepted `var` declarations.
|
||||
- [x] Predeclare `let` and `const` bindings in every lexical scope, including program/block bodies, switch bodies, and
|
||||
loop headers, so reads before initialization and self- or cross-referential initializers observe the JavaScript
|
||||
temporal dead zone.
|
||||
- [x] Function declarations are hoisted across all cases of a `switch`, like any other statement list.
|
||||
- [ ] Hoist function declarations accepted directly in switch cases.
|
||||
- [x] Computed object destructuring keys such as `const { [field]: value } = record`.
|
||||
- [x] Object destructuring from arrays, such as `const { length } = values`.
|
||||
- [x] Array binding and assignment destructuring from strings, Maps, Sets, URLSearchParams, custom synchronous
|
||||
@@ -253,13 +250,12 @@ ultimate source of truth.
|
||||
## Strings
|
||||
|
||||
- [x] Case/normalization: `toLowerCase`, `toUpperCase`, `normalize`.
|
||||
- [x] Trimming: `trim`, `trimStart`, and `trimEnd`, plus the Annex B `trimLeft` and `trimRight` aliases.
|
||||
- [x] Trimming: `trim`, `trimStart`, and `trimEnd`.
|
||||
- [x] Searching/tests: `includes`, `startsWith`, `endsWith`, `indexOf`, `lastIndexOf`, and `search`.
|
||||
- [x] Slicing/access: `slice`, `substring`, Annex B `substr`, `at`, `charAt`, `charCodeAt`, and `codePointAt`.
|
||||
- [x] Slicing/access: `slice`, `substring`, `at`, `charAt`, `charCodeAt`, and `codePointAt`.
|
||||
- [x] Construction/transformation: `split`, `concat`, `repeat`, `padStart`, `padEnd`, `replace`, and `replaceAll`.
|
||||
- [x] Regular-expression integration: `match`, materialized `matchAll`, `replace`, `replaceAll`, `split`, and `search`.
|
||||
- [x] `localeCompare`; locale and options arguments are currently ignored.
|
||||
- [x] `isWellFormed` and `toWellFormed`.
|
||||
- [x] `toString`, `length`, numeric indexing, spread, and `for...of` by Unicode code point.
|
||||
- [x] Static `String.fromCharCode` and `String.fromCodePoint`.
|
||||
- [x] Native argument coercion for supported String methods; for example, `includes(1)` and `slice("1")` coerce like
|
||||
@@ -320,7 +316,6 @@ ultimate source of truth.
|
||||
- [x] Local and UTC Date setters, including native argument coercion, mutation, rollover, invalid-Date recovery, and
|
||||
`TimeClip` behavior.
|
||||
- [x] `Date.prototype.toUTCString` and its `toGMTString` alias.
|
||||
- [x] `toDateString` and `toTimeString` in the host's local timezone.
|
||||
- [x] Native one-argument Date coercion for supported values, including booleans, null, arrays, and plain objects.
|
||||
- [x] Native Date loose-equality and default primitive-coercion semantics, using CodeMode's deterministic ISO string
|
||||
representation for the string primitive.
|
||||
@@ -364,14 +359,6 @@ ultimate source of truth.
|
||||
`entries`, `toString`, and `size`.
|
||||
- [x] URL values serialize to their href; URLSearchParams serialize to `{}`.
|
||||
|
||||
## Web platform helpers
|
||||
|
||||
- [x] `atob` and `btoa` with forgiving-base64 decoding and WebIDL string conversion; invalid input throws an Error
|
||||
named `InvalidCharacterError`, since there is no `DOMException`.
|
||||
- [x] `crypto.randomUUID()`.
|
||||
- [ ] `crypto.getRandomValues` and `crypto.subtle`, `TextEncoder`/`TextDecoder`, and `Blob`: these need a binary
|
||||
value type, which the JSON-like data model does not have yet.
|
||||
|
||||
## Errors and diagnostics
|
||||
|
||||
- [x] `Error`, `TypeError`, `RangeError`, `SyntaxError`, `ReferenceError`, `EvalError`, and `URIError`, callable with
|
||||
@@ -388,6 +375,6 @@ ultimate source of truth.
|
||||
shift them. The diagnostic names the rejected node type and attaches a short orientation to the supported
|
||||
subset; this matrix is the full reference.
|
||||
- [x] Model-visible host failure messages and underlying causes, including output-validation errors.
|
||||
- [x] Caught errors do not distinguish user throws, interpreter failures, and tool failures; a program sees one
|
||||
Error-shaped value with `name` and `message` in `catch`, rejection handlers, and `Promise.allSettled` reasons.
|
||||
This is deliberate: the program should handle a failure the same way regardless of where it originated.
|
||||
- [ ] Distinguish user-thrown failures from interpreter defects and explicit tool refusals from internal tool
|
||||
failures; preserve those categories in caught errors, promise rejection handlers, and `Promise.allSettled`
|
||||
reasons.
|
||||
|
||||
@@ -105,7 +105,7 @@ export type Result = typeof Result.Type
|
||||
|
||||
/** Reusable confined runtime over explicit tools. */
|
||||
export type Runtime<R = never> = {
|
||||
readonly catalog: ReadonlyArray<ToolDescription>
|
||||
readonly catalog: () => ReadonlyArray<ToolDescription>
|
||||
readonly execute: (code: string) => Effect.Effect<Result, never, R>
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ export const make = <const Provided extends Record<string, unknown> = {}>(
|
||||
const prepared = ToolRuntime.prepare((options.tools ?? {}) as Tools<Services<Provided>>)
|
||||
const limits = resolveExecutionLimits(options.limits)
|
||||
return {
|
||||
catalog: prepared.catalog,
|
||||
catalog: () => prepared.catalog,
|
||||
execute: (code) => executeProgram(code, prepared, limits, options),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import { regexpGlobal } from "../stdlib/regexp.js"
|
||||
import { stringGlobal } from "../stdlib/string.js"
|
||||
import { uriGlobal, urlGlobal, urlSearchParamsGlobal } from "../stdlib/url.js"
|
||||
import { coercion, errorConstructors } from "../stdlib/value.js"
|
||||
import { atobGlobal, btoaGlobal, cryptoGlobal } from "../stdlib/web.js"
|
||||
import { ToolReference } from "../tool-runtime.js"
|
||||
import { errorGlobal } from "./errors.js"
|
||||
import { HostFunction } from "./host.js"
|
||||
@@ -72,8 +71,5 @@ export const globals = <R>(host: Host<R>): ReadonlyArray<readonly [string, unkno
|
||||
["encodeURIComponent", uriGlobal("encodeURIComponent")],
|
||||
["decodeURI", uriGlobal("decodeURI")],
|
||||
["decodeURIComponent", uriGlobal("decodeURIComponent")],
|
||||
["atob", atobGlobal],
|
||||
["btoa", btoaGlobal],
|
||||
["crypto", cryptoGlobal],
|
||||
...[...errorConstructors].map((name) => [name, errorGlobal(name, host.runner)] as const),
|
||||
]
|
||||
|
||||
@@ -118,11 +118,9 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
|
||||
result = value.trim()
|
||||
break
|
||||
case "trimStart":
|
||||
case "trimLeft":
|
||||
result = value.trimStart()
|
||||
break
|
||||
case "trimEnd":
|
||||
case "trimRight":
|
||||
result = value.trimEnd()
|
||||
break
|
||||
// Locale/options are deliberately unsupported; comparison uses the host default locale.
|
||||
@@ -243,15 +241,6 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
|
||||
case "substring":
|
||||
result = value.substring(optNum(0) ?? 0, optNum(1))
|
||||
break
|
||||
case "substr":
|
||||
result = value.substr(optNum(0) ?? 0, optNum(1))
|
||||
break
|
||||
case "isWellFormed":
|
||||
result = value.isWellFormed()
|
||||
break
|
||||
case "toWellFormed":
|
||||
result = value.toWellFormed()
|
||||
break
|
||||
case "charCodeAt":
|
||||
result = value.charCodeAt(optNum(0) ?? 0)
|
||||
break
|
||||
|
||||
@@ -32,17 +32,6 @@ export class PromiseRuntime<R> {
|
||||
|
||||
constructor(private readonly scope: Scope.Scope) {}
|
||||
|
||||
// Resolution bodies need the promise's own identity to reject `resolve(promise)` self-resolution.
|
||||
createWithSelf(
|
||||
body: (self: { promise?: Values.Promise }) => Effect.Effect<unknown, unknown, R>,
|
||||
): Effect.Effect<Values.Promise, never, R> {
|
||||
const self: { promise?: Values.Promise } = {}
|
||||
return Effect.map(this.create(body(self)), (promise) => {
|
||||
self.promise = promise
|
||||
return promise
|
||||
})
|
||||
}
|
||||
|
||||
create(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<Values.Promise, never, R> {
|
||||
return Effect.suspend(() => {
|
||||
// Allocate before forking so reruns get distinct IDs and diagnostics retain creation order.
|
||||
@@ -137,7 +126,11 @@ export const resolvePromise = <R>(
|
||||
node: AstNode,
|
||||
): Effect.Effect<Values.Promise, never, R> => {
|
||||
if (value instanceof Values.Promise) return Effect.succeed(value)
|
||||
return promises.createWithSelf((self) => resolvePromiseValue(runner, value, node, self))
|
||||
const box: { promise?: Values.Promise } = {}
|
||||
return Effect.map(promises.create(resolvePromiseValue(runner, value, node, box)), (promise) => {
|
||||
box.promise = promise
|
||||
return promise
|
||||
})
|
||||
}
|
||||
|
||||
const promiseStatics = ["all", "allSettled", "race", "any", "resolve", "reject"] as const
|
||||
@@ -261,9 +254,11 @@ const constructPromise = <R>(
|
||||
}
|
||||
return Effect.gen(function* () {
|
||||
const deferred = Deferred.makeUnsafe<unknown, unknown>()
|
||||
const promise = yield* promises.createWithSelf((self) =>
|
||||
Effect.flatMap(Deferred.await(deferred), (value) => resolvePromiseValue(runner, value, node, self)),
|
||||
const box: { promise?: Values.Promise } = {}
|
||||
const promise = yield* promises.create(
|
||||
Effect.flatMap(Deferred.await(deferred), (value) => resolvePromiseValue(runner, value, node, box)),
|
||||
)
|
||||
box.promise = promise
|
||||
const resolve = capability("resolve", (value) => Deferred.doneUnsafe(deferred, Exit.succeed(value)))
|
||||
const reject = capability("reject", (value) => Deferred.doneUnsafe(deferred, Exit.fail(new ProgramThrow(value))))
|
||||
const executed = yield* Effect.exit(runner.invokeFunction(executor, [resolve, reject]))
|
||||
@@ -315,16 +310,19 @@ const chainReaction = <R>(
|
||||
method: string,
|
||||
node: AstNode,
|
||||
): Effect.Effect<Values.Promise, never, R> => {
|
||||
return promises.createWithSelf((self) =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* reactionExit(promises, source)
|
||||
const handler = Exit.isSuccess(exit) ? onFulfilled : onRejected
|
||||
if (handler === undefined) return yield* exit
|
||||
const input = Exit.isSuccess(exit) ? exit.value : caughtErrorValue(Cause.squash(exit.cause))
|
||||
const result = yield* applyCollectionCallback(runner, handler, method, node)([input])
|
||||
return yield* resolvePromiseValue(runner, result, node, self)
|
||||
}),
|
||||
)
|
||||
const box: { promise?: Values.Promise } = {}
|
||||
const body = Effect.gen(function* () {
|
||||
const exit = yield* reactionExit(promises, source)
|
||||
const handler = Exit.isSuccess(exit) ? onFulfilled : onRejected
|
||||
if (handler === undefined) return yield* exit
|
||||
const input = Exit.isSuccess(exit) ? exit.value : caughtErrorValue(Cause.squash(exit.cause))
|
||||
const result = yield* applyCollectionCallback(runner, handler, method, node)([input])
|
||||
return yield* resolvePromiseValue(runner, result, node, box)
|
||||
})
|
||||
return Effect.map(promises.create(body), (derived) => {
|
||||
box.promise = derived
|
||||
return derived
|
||||
})
|
||||
}
|
||||
|
||||
const chainFinally = <R>(
|
||||
|
||||
@@ -90,18 +90,6 @@ import { enumerableSource } from "../stdlib/object.js"
|
||||
import { coerceToNumber, coerceToString, compoundOperators, errorBrandName } from "../stdlib/value.js"
|
||||
import { Values } from "../values.js"
|
||||
|
||||
// What a loop does with its body's result: exit with a StatementResult, or undefined to keep iterating.
|
||||
// Unlabelled break ends this loop; a label the loop does not carry propagates outward.
|
||||
const loopExit = (result: StatementResult, labels: ReadonlySet<string> | undefined): StatementResult | undefined => {
|
||||
if (result.kind === "return") return result
|
||||
if (result.kind === "break") {
|
||||
if (result.label !== undefined && !labels?.has(result.label)) return result
|
||||
return { kind: "none" }
|
||||
}
|
||||
if (result.kind === "continue" && result.label !== undefined && !labels?.has(result.label)) return result
|
||||
return undefined
|
||||
}
|
||||
|
||||
const calleeDescription = (callee: Expression | Super | undefined): string => {
|
||||
if (callee?.type === "Identifier") return callee.name
|
||||
if (callee?.type === "MemberExpression") {
|
||||
@@ -170,51 +158,6 @@ const collectPatternNames = (pattern: Pattern, out: Array<string> = []): Array<s
|
||||
return out
|
||||
}
|
||||
|
||||
// `var` names declared anywhere in a function body except inside nested functions, which own theirs.
|
||||
// Memoized per body: a function's var names never change, and hoisting runs on every call.
|
||||
const varNames = new WeakMap<ReadonlyArray<Statement | ModuleDeclaration>, ReadonlyArray<string>>()
|
||||
const collectVarNames = (
|
||||
node: Statement | ModuleDeclaration | null | undefined,
|
||||
out: Array<string> = [],
|
||||
): Array<string> => {
|
||||
if (!node) return out
|
||||
switch (node.type) {
|
||||
case "VariableDeclaration":
|
||||
if (node.kind === "var") for (const declaration of node.declarations) collectPatternNames(declaration.id, out)
|
||||
break
|
||||
case "BlockStatement":
|
||||
for (const statement of node.body) collectVarNames(statement, out)
|
||||
break
|
||||
case "IfStatement":
|
||||
collectVarNames(node.consequent, out)
|
||||
collectVarNames(node.alternate, out)
|
||||
break
|
||||
case "ForStatement":
|
||||
if (node.init?.type === "VariableDeclaration") collectVarNames(node.init, out)
|
||||
collectVarNames(node.body, out)
|
||||
break
|
||||
case "ForInStatement":
|
||||
case "ForOfStatement":
|
||||
if (node.left.type === "VariableDeclaration") collectVarNames(node.left, out)
|
||||
collectVarNames(node.body, out)
|
||||
break
|
||||
case "WhileStatement":
|
||||
case "DoWhileStatement":
|
||||
case "LabeledStatement":
|
||||
collectVarNames(node.body, out)
|
||||
break
|
||||
case "SwitchStatement":
|
||||
for (const item of node.cases) for (const statement of item.consequent) collectVarNames(statement, out)
|
||||
break
|
||||
case "TryStatement":
|
||||
collectVarNames(node.block, out)
|
||||
collectVarNames(node.handler?.body, out)
|
||||
collectVarNames(node.finalizer, out)
|
||||
break
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const loopDeclaration = (left: VariableDeclaration | Pattern, statement: "for...of" | "for...in") => {
|
||||
if (left.type !== "VariableDeclaration") return undefined
|
||||
const declaration = left.declarations.length === 1 ? left.declarations[0] : undefined
|
||||
@@ -321,7 +264,6 @@ class Frame<R> {
|
||||
return Effect.gen(function* () {
|
||||
self.predeclareLexical(program.body)
|
||||
self.hoistFunctions(program.body)
|
||||
self.hoistVars(program.body)
|
||||
let value: unknown = undefined
|
||||
for (const [index, statement] of program.body.entries()) {
|
||||
if (index === program.body.length - 1 && statement.type === "ExpressionStatement") {
|
||||
@@ -445,20 +387,6 @@ class Frame<R> {
|
||||
}
|
||||
}
|
||||
|
||||
// Hoisted `var` bindings start undefined, or copy a same-named parameter. Function bodies hoist
|
||||
// into their own scope above the parameters so closures in parameter defaults keep seeing outer names.
|
||||
private hoistVars(statements: ReadonlyArray<Statement | ModuleDeclaration>, parameters?: Map<string, Binding>): void {
|
||||
const names =
|
||||
varNames.get(statements) ??
|
||||
statements.reduce<Array<string>>((out, statement) => collectVarNames(statement, out), [])
|
||||
varNames.set(statements, names)
|
||||
const scope = this.scopes.current()
|
||||
for (const name of names) {
|
||||
if (scope.has(name)) continue
|
||||
scope.set(name, { mutable: true, value: parameters?.get(name)?.value, initialized: true })
|
||||
}
|
||||
}
|
||||
|
||||
private predeclareLexical(statements: ReadonlyArray<Statement | ModuleDeclaration>): void {
|
||||
for (const statement of statements) {
|
||||
if (statement.type !== "VariableDeclaration") continue
|
||||
@@ -496,9 +424,7 @@ class Frame<R> {
|
||||
self.scopes.push()
|
||||
return yield* Effect.gen(function* () {
|
||||
const cases = node.cases
|
||||
const statements = cases.flatMap((branch) => branch.consequent)
|
||||
self.predeclareLexical(statements)
|
||||
self.hoistFunctions(statements)
|
||||
self.predeclareLexical(cases.flatMap((branch) => branch.consequent))
|
||||
let defaultIndex: number | undefined
|
||||
let selected: number | undefined
|
||||
for (const [index, branch] of cases.entries()) {
|
||||
@@ -540,8 +466,21 @@ class Frame<R> {
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
while (yield* self.evaluateExpression(node.test)) {
|
||||
const exit = loopExit(yield* self.evaluateStatement(node.body), labels)
|
||||
if (exit !== undefined) return exit
|
||||
const result = yield* self.evaluateStatement(node.body)
|
||||
|
||||
if (result.kind === "continue") {
|
||||
if (result.label !== undefined && !labels?.has(result.label)) return result
|
||||
continue
|
||||
}
|
||||
|
||||
if (result.kind === "break") {
|
||||
if (result.label !== undefined && !labels?.has(result.label)) return result
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
}
|
||||
|
||||
if (result.kind === "return") {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
@@ -555,8 +494,21 @@ class Frame<R> {
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
do {
|
||||
const exit = loopExit(yield* self.evaluateStatement(node.body), labels)
|
||||
if (exit !== undefined) return exit
|
||||
const result = yield* self.evaluateStatement(node.body)
|
||||
|
||||
if (result.kind === "continue") {
|
||||
if (result.label !== undefined && !labels?.has(result.label)) return result
|
||||
continue
|
||||
}
|
||||
|
||||
if (result.kind === "break") {
|
||||
if (result.label !== undefined && !labels?.has(result.label)) return result
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
}
|
||||
|
||||
if (result.kind === "return") {
|
||||
return result
|
||||
}
|
||||
} while (yield* self.evaluateExpression(node.test))
|
||||
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
@@ -602,13 +554,27 @@ class Frame<R> {
|
||||
nextIteration()
|
||||
|
||||
while (testNode ? yield* self.evaluateExpression(testNode) : true) {
|
||||
const exit = loopExit(yield* self.evaluateStatement(node.body), labels)
|
||||
if (exit !== undefined) return exit
|
||||
const result = yield* self.evaluateStatement(node.body)
|
||||
|
||||
if (result.kind === "return") {
|
||||
return result
|
||||
}
|
||||
|
||||
if (result.kind === "break") {
|
||||
if (result.label !== undefined && !labels?.has(result.label)) return result
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
}
|
||||
|
||||
if (result.kind === "continue" && result.label !== undefined && !labels?.has(result.label)) return result
|
||||
|
||||
nextIteration()
|
||||
if (updateNode) {
|
||||
yield* self.evaluateExpression(updateNode)
|
||||
}
|
||||
|
||||
if (result.kind === "continue") {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
@@ -651,12 +617,10 @@ class Frame<R> {
|
||||
|
||||
const evaluateBody = (value: unknown) =>
|
||||
Effect.gen(function* () {
|
||||
if (declared?.lexical) {
|
||||
if (declared) {
|
||||
self.scopes.push()
|
||||
self.predeclarePattern(declared.pattern, declared.mutable, left)
|
||||
yield* self.declarePattern(declared.pattern, value, declared.mutable, left, true)
|
||||
} else if (declared) {
|
||||
yield* self.assignPattern(declared.pattern, value, left)
|
||||
if (declared.lexical) self.predeclarePattern(declared.pattern, declared.mutable, left)
|
||||
yield* self.declarePattern(declared.pattern, value, declared.mutable, left, declared.lexical)
|
||||
} else if (assignment) {
|
||||
yield* self.assignPattern(assignment, value, left)
|
||||
}
|
||||
@@ -664,7 +628,7 @@ class Frame<R> {
|
||||
}).pipe(
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
if (declared?.lexical) self.scopes.pop()
|
||||
if (declared) self.scopes.pop()
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -683,10 +647,22 @@ class Frame<R> {
|
||||
}
|
||||
return yield* Effect.failCause(bodyExit.cause)
|
||||
}
|
||||
const exit = loopExit(bodyExit.value, labels)
|
||||
if (exit !== undefined) {
|
||||
const result = bodyExit.value
|
||||
|
||||
if (result.kind === "return") {
|
||||
yield* close()
|
||||
return exit
|
||||
return result
|
||||
}
|
||||
|
||||
if (result.kind === "break") {
|
||||
yield* close()
|
||||
if (result.label !== undefined && !labels?.has(result.label)) return result
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
}
|
||||
|
||||
if (result.kind === "continue" && result.label !== undefined && !labels?.has(result.label)) {
|
||||
yield* close()
|
||||
return result
|
||||
}
|
||||
}
|
||||
}).pipe(
|
||||
@@ -898,12 +874,10 @@ class Frame<R> {
|
||||
|
||||
for (const key of keys) {
|
||||
const result = yield* Effect.gen(function* () {
|
||||
if (declared?.lexical) {
|
||||
if (declared) {
|
||||
self.scopes.push()
|
||||
self.predeclarePattern(declared.pattern, declared.mutable, left)
|
||||
yield* self.declarePattern(declared.pattern, key, declared.mutable, left, true)
|
||||
} else if (declared) {
|
||||
yield* self.assignPattern(declared.pattern, key, left)
|
||||
if (declared.lexical) self.predeclarePattern(declared.pattern, declared.mutable, left)
|
||||
yield* self.declarePattern(declared.pattern, key, declared.mutable, left, declared.lexical)
|
||||
} else if (assignmentName) {
|
||||
self.scopes.set(assignmentName, key, left)
|
||||
}
|
||||
@@ -911,13 +885,24 @@ class Frame<R> {
|
||||
}).pipe(
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
if (declared?.lexical) self.scopes.pop()
|
||||
if (declared) self.scopes.pop()
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const exit = loopExit(result, labels)
|
||||
if (exit !== undefined) return exit
|
||||
if (result.kind === "return") {
|
||||
return result
|
||||
}
|
||||
|
||||
if (result.kind === "break") {
|
||||
if (result.label !== undefined && !labels?.has(result.label)) return result
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
}
|
||||
|
||||
if (result.kind === "continue") {
|
||||
if (result.label !== undefined && !labels?.has(result.label)) return result
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
@@ -1018,13 +1003,8 @@ class Frame<R> {
|
||||
}
|
||||
|
||||
const init = declaration.init
|
||||
// `var x` alone is a no-op: the binding was hoisted on function entry.
|
||||
if (kind === "var") {
|
||||
if (init) yield* self.assignPattern(declaration.id, yield* self.evaluateExpression(init), declaration)
|
||||
continue
|
||||
}
|
||||
const value = init ? yield* self.evaluateExpression(init) : undefined
|
||||
yield* self.declarePattern(declaration.id, value, kind !== "const", declaration, true)
|
||||
yield* self.declarePattern(declaration.id, value, kind !== "const", declaration, kind !== "var")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1645,8 +1625,6 @@ class Frame<R> {
|
||||
}
|
||||
|
||||
if (fn.body.type === "BlockStatement") {
|
||||
invocation.scopes.push()
|
||||
invocation.hoistVars(fn.body.body, paramScope)
|
||||
const result = yield* invocation.evaluateStatement(fn.body)
|
||||
return result.kind === "return" ? result.value : undefined
|
||||
}
|
||||
@@ -1655,8 +1633,16 @@ class Frame<R> {
|
||||
})
|
||||
if (fn.generator) return Effect.succeed(this.createGenerator(invocation, run, fn.async))
|
||||
if (!fn.async) return run
|
||||
return this.runtime.promises.createWithSelf((self) =>
|
||||
Effect.flatMap(run, (value) => resolvePromiseValue(invocation.runtime.runner, value, fn.body, self)),
|
||||
// The initial yield assigns the promise before the body can self-resolve.
|
||||
const box: { promise?: Values.Promise } = {}
|
||||
return Effect.map(
|
||||
this.createPromise(
|
||||
Effect.flatMap(run, (value) => resolvePromiseValue(invocation.runtime.runner, value, fn.body, box)),
|
||||
),
|
||||
(promise) => {
|
||||
box.promise = promise
|
||||
return promise
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -29,8 +29,6 @@ export const dateMethods = new Set([
|
||||
"toISOString",
|
||||
"toJSON",
|
||||
"toString",
|
||||
"toDateString",
|
||||
"toTimeString",
|
||||
"toUTCString",
|
||||
"toGMTString",
|
||||
"getFullYear",
|
||||
@@ -105,10 +103,6 @@ export const invokeDateMethod = (
|
||||
return Number.isFinite(value.time) ? hosted.toISOString() : null
|
||||
case "toString":
|
||||
return coerceToString(value)
|
||||
case "toDateString":
|
||||
return hosted.toDateString()
|
||||
case "toTimeString":
|
||||
return hosted.toTimeString()
|
||||
case "toUTCString":
|
||||
case "toGMTString":
|
||||
return hosted.toUTCString()
|
||||
|
||||
@@ -134,6 +134,19 @@ const constructObject = (args: Array<unknown>, node: AstNode): unknown => {
|
||||
)
|
||||
}
|
||||
|
||||
// Tool references are not data; only Object.keys(tools) reads them, for tool names.
|
||||
const rejectTools = (name: string, args: Array<unknown>, node: AstNode): void => {
|
||||
if (!(args[0] instanceof ToolReference)) return
|
||||
throw new InterpreterRuntimeError(
|
||||
`Object.${name}(...) cannot read tool references: they are not plain data. Use Object.keys(tools) for names, or search({ query }) for signatures.`,
|
||||
node,
|
||||
"InvalidDataValue",
|
||||
)
|
||||
}
|
||||
|
||||
const objectStatic = (name: string, impl: (args: Array<unknown>, node: AstNode) => unknown) =>
|
||||
sync(`Object.${name}`, impl)
|
||||
|
||||
// Object constructs identically with or without new, like JS. Only `keys` copies its result into the
|
||||
// program; `values`, `entries`, `assign`, and `fromEntries` hand back the program's own values.
|
||||
export const objectGlobal = <R>(runner: Runner<R>, toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>) =>
|
||||
@@ -151,28 +164,32 @@ export const objectGlobal = <R>(runner: Runner<R>, toolKeys: (path: ReadonlyArra
|
||||
"Object.keys result",
|
||||
),
|
||||
),
|
||||
values: sync("Object.values", (args, node) =>
|
||||
values: objectStatic("values", (args, node) =>
|
||||
Object.values(enumerableSource("Object.values(...)", args[0], node)),
|
||||
),
|
||||
entries: sync("Object.entries", (args, node) =>
|
||||
entries: objectStatic("entries", (args, node) =>
|
||||
Object.entries(enumerableSource("Object.entries(...)", args[0], node)).map(([key, item]) => [key, item]),
|
||||
),
|
||||
hasOwn: sync("Object.hasOwn", (args, node) =>
|
||||
hasOwn: objectStatic("hasOwn", (args, node) =>
|
||||
Object.hasOwn(
|
||||
enumerableSource("Object.hasOwn(...)", args[0], node),
|
||||
args[1] === AsyncIteratorSymbol || args[1] === IteratorSymbol ? args[1] : String(args[1]),
|
||||
),
|
||||
),
|
||||
is: sync("Object.is", (args, node) => {
|
||||
is: objectStatic("is", (args, node) => {
|
||||
if (containsOpaqueReference(args[0]) || containsOpaqueReference(args[1])) {
|
||||
throw new InterpreterRuntimeError("Object.is requires data values.", node, "InvalidDataValue")
|
||||
}
|
||||
return Object.is(args[0], args[1])
|
||||
}),
|
||||
assign: sync("Object.assign", objectAssign),
|
||||
assign: objectStatic("assign", objectAssign),
|
||||
fromEntries: new HostFunction<R>({
|
||||
name: "Object.fromEntries",
|
||||
call: (args, node) => Effect.suspend(() => objectFromEntries(runner, args[0], node)),
|
||||
call: (args, node) =>
|
||||
Effect.suspend(() => {
|
||||
rejectTools("fromEntries", args, node)
|
||||
return objectFromEntries(runner, args[0], node)
|
||||
}),
|
||||
}),
|
||||
groupBy: groupBy(runner, "Object"),
|
||||
},
|
||||
|
||||
@@ -8,12 +8,9 @@ export const stringMethods = new Set([
|
||||
"trim",
|
||||
"trimStart",
|
||||
"trimEnd",
|
||||
"trimLeft",
|
||||
"trimRight",
|
||||
"split",
|
||||
"slice",
|
||||
"substring",
|
||||
"substr",
|
||||
"includes",
|
||||
"startsWith",
|
||||
"endsWith",
|
||||
@@ -35,8 +32,6 @@ export const stringMethods = new Set([
|
||||
"search",
|
||||
"localeCompare",
|
||||
"normalize",
|
||||
"isWellFormed",
|
||||
"toWellFormed",
|
||||
])
|
||||
|
||||
const codeUnits = (name: string, op: (...codes: Array<number>) => string) =>
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { HostNamespace, sync } from "../interpreter/host.js"
|
||||
import { InterpreterRuntimeError } from "../interpreter/model.js"
|
||||
import { coerceToString } from "./value.js"
|
||||
|
||||
// WebIDL DOMString conversion: a missing argument is a TypeError, anything else stringifies.
|
||||
const base64 = (name: "atob" | "btoa") =>
|
||||
sync(name, (args, node) => {
|
||||
if (args.length === 0) {
|
||||
throw new InterpreterRuntimeError(`${name} requires 1 argument, but only 0 were provided.`, node).as("TypeError")
|
||||
}
|
||||
const input = coerceToString(args[0])
|
||||
try {
|
||||
return name === "atob" ? atob(input) : btoa(input)
|
||||
} catch {
|
||||
throw new InterpreterRuntimeError("The string contains invalid characters.", node).as("InvalidCharacterError")
|
||||
}
|
||||
})
|
||||
|
||||
export const atobGlobal = base64("atob")
|
||||
export const btoaGlobal = base64("btoa")
|
||||
|
||||
export const cryptoGlobal = new HostNamespace("crypto", {
|
||||
randomUUID: sync("crypto.randomUUID", () => crypto.randomUUID()),
|
||||
})
|
||||
@@ -1,11 +0,0 @@
|
||||
# The 3-Clause BSD License
|
||||
|
||||
Copyright © web-platform-tests contributors
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
||||
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
||||
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -528,7 +528,7 @@ describe("CodeMode schema flexibility", () => {
|
||||
})
|
||||
const runtime = CodeMode.make({ tools: { adapter: { call } } })
|
||||
|
||||
expect(runtime.catalog).toStrictEqual([
|
||||
expect(runtime.catalog()).toStrictEqual([
|
||||
{
|
||||
path: "adapter.call",
|
||||
description: "Call an adapter-described tool",
|
||||
@@ -611,7 +611,7 @@ describe("CodeMode schema flexibility", () => {
|
||||
})
|
||||
const runtime = CodeMode.make({ tools: { users: { lookup } } })
|
||||
|
||||
expect(runtime.catalog).toStrictEqual([
|
||||
expect(runtime.catalog()).toStrictEqual([
|
||||
{
|
||||
path: "users.lookup",
|
||||
description: "Look up a user",
|
||||
@@ -631,7 +631,7 @@ describe("CodeMode schema flexibility", () => {
|
||||
execute: () => Effect.succeed("pong"),
|
||||
})
|
||||
const runtime = CodeMode.make({ tools: { net: { ping } } })
|
||||
expect(runtime.catalog[0]?.signature).toBe("tools.net.ping(input: {\n host: string,\n}): Promise<void>")
|
||||
expect(runtime.catalog()[0]?.signature).toBe("tools.net.ping(input: {\n host: string,\n}): Promise<void>")
|
||||
|
||||
const result = await Effect.runPromise(runtime.execute(`return await tools.net.ping({ host: "example.test" })`))
|
||||
expect(result.ok).toBe(true)
|
||||
@@ -684,7 +684,7 @@ describe("CodeMode public contract", () => {
|
||||
|
||||
test("describes the catalog and keeps the search built-in registered", async () => {
|
||||
const runtime = CodeMode.make({ tools })
|
||||
expect(runtime.catalog).toStrictEqual([
|
||||
expect(runtime.catalog()).toStrictEqual([
|
||||
{
|
||||
path: "orders.lookup",
|
||||
description: "Look up an order by ID",
|
||||
@@ -726,8 +726,8 @@ describe("CodeMode public contract", () => {
|
||||
const first = CodeMode.make({ tools: { zeta: { zeta, alpha }, alpha: { zeta, alpha } } })
|
||||
const second = CodeMode.make({ tools: { alpha: { alpha, zeta }, zeta: { alpha, zeta } } })
|
||||
|
||||
expect(first.catalog).toStrictEqual(second.catalog)
|
||||
expect(first.catalog.map((tool) => tool.path)).toEqual(["alpha.alpha", "alpha.zeta", "zeta.alpha", "zeta.zeta"])
|
||||
expect(first.catalog()).toStrictEqual(second.catalog())
|
||||
expect(first.catalog().map((tool) => tool.path)).toEqual(["alpha.alpha", "alpha.zeta", "zeta.alpha", "zeta.zeta"])
|
||||
})
|
||||
|
||||
test("renders bracket notation for tool names that are not JavaScript identifiers", async () => {
|
||||
@@ -739,7 +739,7 @@ describe("CodeMode public contract", () => {
|
||||
})
|
||||
const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } })
|
||||
|
||||
expect(runtime.catalog).toStrictEqual([
|
||||
expect(runtime.catalog()).toStrictEqual([
|
||||
{
|
||||
path: "context7.resolve-library-id",
|
||||
description: "Resolve a library ID",
|
||||
|
||||
-82
@@ -1,82 +0,0 @@
|
||||
[
|
||||
["", []],
|
||||
["abcd", [105, 183, 29]],
|
||||
[" abcd", [105, 183, 29]],
|
||||
["abcd ", [105, 183, 29]],
|
||||
[" abcd===", null],
|
||||
["abcd=== ", null],
|
||||
["abcd ===", null],
|
||||
["a", null],
|
||||
["ab", [105]],
|
||||
["abc", [105, 183]],
|
||||
["abcde", null],
|
||||
["𐀀", null],
|
||||
["=", null],
|
||||
["==", null],
|
||||
["===", null],
|
||||
["====", null],
|
||||
["=====", null],
|
||||
["a=", null],
|
||||
["a==", null],
|
||||
["a===", null],
|
||||
["a====", null],
|
||||
["a=====", null],
|
||||
["ab=", null],
|
||||
["ab==", [105]],
|
||||
["ab===", null],
|
||||
["ab====", null],
|
||||
["ab=====", null],
|
||||
["abc=", [105, 183]],
|
||||
["abc==", null],
|
||||
["abc===", null],
|
||||
["abc====", null],
|
||||
["abc=====", null],
|
||||
["abcd=", null],
|
||||
["abcd==", null],
|
||||
["abcd===", null],
|
||||
["abcd====", null],
|
||||
["abcd=====", null],
|
||||
["abcde=", null],
|
||||
["abcde==", null],
|
||||
["abcde===", null],
|
||||
["abcde====", null],
|
||||
["abcde=====", null],
|
||||
["=a", null],
|
||||
["=a=", null],
|
||||
["a=b", null],
|
||||
["a=b=", null],
|
||||
["ab=c", null],
|
||||
["ab=c=", null],
|
||||
["abc=d", null],
|
||||
["abc=d=", null],
|
||||
["ab\u000Bcd", null],
|
||||
["ab\u3000cd", null],
|
||||
["ab\u3001cd", null],
|
||||
["ab\tcd", [105, 183, 29]],
|
||||
["ab\ncd", [105, 183, 29]],
|
||||
["ab\fcd", [105, 183, 29]],
|
||||
["ab\rcd", [105, 183, 29]],
|
||||
["ab cd", [105, 183, 29]],
|
||||
["ab\u00a0cd", null],
|
||||
["ab\t\n\f\r cd", [105, 183, 29]],
|
||||
[" \t\n\f\r ab\t\n\f\r cd\t\n\f\r ", [105, 183, 29]],
|
||||
["ab\t\n\f\r =\t\n\f\r =\t\n\f\r ", [105]],
|
||||
["A", null],
|
||||
["/A", [252]],
|
||||
["//A", [255, 240]],
|
||||
["///A", [255, 255, 192]],
|
||||
["////A", null],
|
||||
["/", null],
|
||||
["A/", [3]],
|
||||
["AA/", [0, 15]],
|
||||
["AAAA/", null],
|
||||
["AAA/", [0, 0, 63]],
|
||||
["\u0000nonsense", null],
|
||||
["abcd\u0000nonsense", null],
|
||||
["YQ", [97]],
|
||||
["YR", [97]],
|
||||
["~~", null],
|
||||
["..", null],
|
||||
["--", null],
|
||||
["__", null]
|
||||
]
|
||||
@@ -491,8 +491,12 @@ describe("CodeMode-specific string behavior", () => {
|
||||
expect(await value(`try { "x".normalize("nope"); return "no" } catch (e) { return e.message }`)).toContain('"NFC"')
|
||||
})
|
||||
|
||||
test("exposes the Annex B string aliases every engine ships", async () => {
|
||||
expect(await value(`return [" x ".trimLeft(), " x ".trimRight(), "abc".substr(1, 1)]`)).toEqual(["x ", " x", "b"])
|
||||
test("does not expose obsolete string aliases", async () => {
|
||||
expect(await value(`return [typeof "x".trimLeft, typeof "x".trimRight, typeof "x".substr]`)).toEqual([
|
||||
"undefined",
|
||||
"undefined",
|
||||
"undefined",
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -740,7 +740,7 @@ describe("JSDoc signatures in catalogs and search results", () => {
|
||||
"}",
|
||||
].join("\n")
|
||||
const signature = `tools.constrained(input: ${type}): Promise<${type}>`
|
||||
expect(runtime.catalog[0]?.signature).toBe(signature)
|
||||
expect(runtime.catalog()[0]?.signature).toBe(signature)
|
||||
const result = await Effect.runPromise(runtime.execute('return search({ query: "tools.constrained" })'))
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) throw new Error("search failed")
|
||||
@@ -796,7 +796,7 @@ describe("JSDoc signatures in catalogs and search results", () => {
|
||||
})
|
||||
|
||||
test("the catalog uses the same JSDoc signatures as search", async () => {
|
||||
const catalog = runtime.catalog
|
||||
const catalog = runtime.catalog()
|
||||
const github = (await search("list issues repository")).items.find(
|
||||
({ path }) => path === "tools.github.list_issues",
|
||||
)!
|
||||
@@ -824,7 +824,7 @@ describe("non-identifier tool paths", () => {
|
||||
const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } })
|
||||
|
||||
test("catalog signatures use bracket notation for dashed tool names", () => {
|
||||
expect(runtime.catalog[0]?.signature).toBe(
|
||||
expect(runtime.catalog()[0]?.signature).toBe(
|
||||
'tools.context7["resolve-library-id"](input: {\n query: string,\n libraryName: string,\n}): Promise<unknown>',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
/**
|
||||
* Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75:
|
||||
* - test/annexB/built-ins/String/prototype/substr/length-falsey.js
|
||||
* - test/annexB/built-ins/String/prototype/substr/length-negative.js
|
||||
* - test/annexB/built-ins/String/prototype/substr/length-positive.js
|
||||
* - test/annexB/built-ins/String/prototype/substr/length-undef.js
|
||||
* - test/annexB/built-ins/String/prototype/substr/start-negative.js
|
||||
* - test/annexB/built-ins/String/prototype/substr/surrogate-pairs.js
|
||||
* - test/built-ins/String/prototype/isWellFormed/returns-boolean.js
|
||||
* - test/built-ins/String/prototype/toWellFormed/returns-well-formed-string.js
|
||||
* - test/built-ins/Date/prototype/toDateString/format.js
|
||||
* - test/built-ins/Date/prototype/toDateString/invalid-date.js
|
||||
* - test/built-ins/Date/prototype/toDateString/negative-year.js
|
||||
* - test/built-ins/Date/prototype/toTimeString/format.js
|
||||
* - test/built-ins/Date/prototype/toTimeString/invalid-date.js
|
||||
*
|
||||
* Copyright (C) 2016, 2017 the V8 project authors. All rights reserved.
|
||||
* Copyright (C) 2018 Richard Gibson. All rights reserved.
|
||||
* Copyright (C) 2022 Jordan Harband. All rights reserved.
|
||||
* Test262 portions are governed by the BSD license in LICENSE.test262.
|
||||
*
|
||||
* The `typeof String.prototype.method` checks are replaced with `typeof "".method` because
|
||||
* CodeMode has no prototype objects.
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { CodeMode } from "../src/index.js"
|
||||
|
||||
const value = async (code: string) => {
|
||||
const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} }))
|
||||
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
|
||||
return result.value
|
||||
}
|
||||
|
||||
describe("String.prototype.substr Test262 parity", () => {
|
||||
test("test/annexB/built-ins/String/prototype/substr/length-falsey.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
return [false, NaN, "", null].flatMap((length) => [0, 1, 2, 3].map((start) => "abc".substr(start, length)))
|
||||
`),
|
||||
).toEqual(Array(16).fill(""))
|
||||
})
|
||||
|
||||
test("test/annexB/built-ins/String/prototype/substr/length-negative.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
return [0, 1, 2, 3].flatMap((start) => [-1, -2, -3, -4].map((length) => "abc".substr(start, length)))
|
||||
`),
|
||||
).toEqual(Array(16).fill(""))
|
||||
})
|
||||
|
||||
test("test/annexB/built-ins/String/prototype/substr/length-positive.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
return [0, 1, 2, 3].map((start) => [1, 2, 3, 4].map((length) => "abc".substr(start, length)))
|
||||
`),
|
||||
).toEqual([
|
||||
["a", "ab", "abc", "abc"],
|
||||
["b", "bc", "bc", "bc"],
|
||||
["c", "c", "c", "c"],
|
||||
["", "", "", ""],
|
||||
])
|
||||
})
|
||||
|
||||
test("test/annexB/built-ins/String/prototype/substr/length-undef.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
return [
|
||||
"abc".substr(0), "abc".substr(1), "abc".substr(2), "abc".substr(3),
|
||||
"abc".substr(0, undefined), "abc".substr(1, undefined), "abc".substr(2, undefined), "abc".substr(3, undefined),
|
||||
]
|
||||
`),
|
||||
).toEqual(["abc", "bc", "c", "", "abc", "bc", "c", ""])
|
||||
})
|
||||
|
||||
test("test/annexB/built-ins/String/prototype/substr/start-negative.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
return ["abc".substr(-1), "abc".substr(-2), "abc".substr(-3), "abc".substr(-4), "abc".substr(-1.1)]
|
||||
`),
|
||||
).toEqual(["c", "bc", "abc", "abc", "c"])
|
||||
})
|
||||
|
||||
test("test/annexB/built-ins/String/prototype/substr/surrogate-pairs.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const pair = "\\ud834\\udf06"
|
||||
return [pair.substr(0), pair.substr(1), pair.substr(2), pair.substr(0, 0), pair.substr(0, 1), pair.substr(0, 2)]
|
||||
`),
|
||||
).toEqual(["\ud834\udf06", "\udf06", "", "", "\ud834", "\ud834\udf06"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("String well-formedness Test262 parity", () => {
|
||||
test("test/built-ins/String/prototype/isWellFormed/returns-boolean.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const leadingPoo = "\\uD83D"
|
||||
const trailingPoo = "\\uDCA9"
|
||||
const wholePoo = leadingPoo + trailingPoo
|
||||
return [
|
||||
typeof "".isWellFormed,
|
||||
("a" + leadingPoo + "c" + leadingPoo + "e").isWellFormed(),
|
||||
("a" + trailingPoo + "c" + trailingPoo + "e").isWellFormed(),
|
||||
("a" + trailingPoo + leadingPoo + "d").isWellFormed(),
|
||||
"a💩c".isWellFormed(),
|
||||
"a\\uD83D\\uDCA9c".isWellFormed(),
|
||||
("a" + leadingPoo + trailingPoo + "d").isWellFormed(),
|
||||
wholePoo.slice(0, 1).isWellFormed(),
|
||||
wholePoo.slice(1).isWellFormed(),
|
||||
"abc".isWellFormed(),
|
||||
"a\\u25A8c".isWellFormed(),
|
||||
]
|
||||
`),
|
||||
).toEqual(["function", false, false, false, true, true, true, false, false, true, true])
|
||||
})
|
||||
|
||||
test("test/built-ins/String/prototype/toWellFormed/returns-well-formed-string.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const replacementChar = "\\uFFFD"
|
||||
const leadingPoo = "\\uD83D"
|
||||
const trailingPoo = "\\uDCA9"
|
||||
const wholePoo = leadingPoo + trailingPoo
|
||||
return [
|
||||
typeof "".toWellFormed,
|
||||
("a" + leadingPoo + "c" + leadingPoo + "e").toWellFormed() === "a" + replacementChar + "c" + replacementChar + "e",
|
||||
("a" + trailingPoo + "c" + trailingPoo + "e").toWellFormed() === "a" + replacementChar + "c" + replacementChar + "e",
|
||||
("a" + trailingPoo + leadingPoo + "d").toWellFormed() === "a" + replacementChar + replacementChar + "d",
|
||||
"a💩c".toWellFormed() === "a💩c",
|
||||
"a\\uD83D\\uDCA9c".toWellFormed() === "a\\uD83D\\uDCA9c",
|
||||
("a" + leadingPoo + trailingPoo + "d").toWellFormed() === "a" + wholePoo + "d",
|
||||
wholePoo.slice(0, 1).toWellFormed() === replacementChar,
|
||||
wholePoo.slice(1).toWellFormed() === replacementChar,
|
||||
"abc".toWellFormed() === "abc",
|
||||
"a\\u25A8c".toWellFormed() === "a\\u25A8c",
|
||||
]
|
||||
`),
|
||||
).toEqual(["function", true, true, true, true, true, true, true, true, true, true])
|
||||
})
|
||||
})
|
||||
|
||||
describe("Date string formatting Test262 parity", () => {
|
||||
test("test/built-ins/Date/prototype/toDateString/format.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const dateRegExp = /^(Sun|Mon|Tue|Wed|Thu|Fri|Sat) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) [0-9]{2} [0-9]{4}$/
|
||||
return [dateRegExp.test(new Date(0).toDateString()), dateRegExp.test(new Date("0020-01-01T00:00:00Z").toDateString())]
|
||||
`),
|
||||
).toEqual([true, true])
|
||||
})
|
||||
|
||||
test("test/built-ins/Date/prototype/toDateString/invalid-date.js", async () => {
|
||||
expect(await value(`return new Date(NaN).toDateString()`)).toBe("Invalid Date")
|
||||
})
|
||||
|
||||
test("test/built-ins/Date/prototype/toDateString/negative-year.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
return ["-000001", "-000012", "-000123", "-001234", "-012345", "-123456"].map(
|
||||
(year) => new Date(year + "-07-01T00:00Z").toDateString().split(" ")[3],
|
||||
)
|
||||
`),
|
||||
).toEqual(["-0001", "-0012", "-0123", "-1234", "-12345", "-123456"])
|
||||
})
|
||||
|
||||
test("test/built-ins/Date/prototype/toTimeString/format.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const timeRegExp = /^[0-9]{2}:[0-9]{2}:[0-9]{2} GMT[+-][0-9]{4}( \\(.+\\))?$/
|
||||
return timeRegExp.test(new Date(0).toTimeString())
|
||||
`),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("test/built-ins/Date/prototype/toTimeString/invalid-date.js", async () => {
|
||||
expect(await value(`return new Date(NaN).toTimeString()`)).toBe("Invalid Date")
|
||||
})
|
||||
})
|
||||
@@ -26,7 +26,7 @@ describe("dotted tool names", () => {
|
||||
const runtime = CodeMode.make({ tools: { api: { "issues.list": echo("List issues", "listed") } } })
|
||||
|
||||
test("a dotted name becomes nested namespaces in the catalog", () => {
|
||||
const catalog = runtime.catalog
|
||||
const catalog = runtime.catalog()
|
||||
expect(catalog).toHaveLength(1)
|
||||
expect(catalog[0]?.path).toBe("api.issues.list")
|
||||
expect(catalog[0]?.signature).toStartWith("tools.api.issues.list(")
|
||||
@@ -51,7 +51,7 @@ describe("dotted tool names", () => {
|
||||
|
||||
test("a top-level dotted name nests from the root", async () => {
|
||||
const flat = CodeMode.make({ tools: { "issues.list": echo("List issues", "flat") } })
|
||||
expect(flat.catalog[0]?.path).toBe("issues.list")
|
||||
expect(flat.catalog()[0]?.path).toBe("issues.list")
|
||||
expect(await value(flat, `return await tools.issues.list({})`)).toBe("flat")
|
||||
})
|
||||
|
||||
@@ -85,7 +85,7 @@ describe("callable namespaces", () => {
|
||||
test("a path can hold a tool and child tools at once", async () => {
|
||||
expect(await value(runtime, `return await tools.issues({})`)).toBe("all")
|
||||
expect(await value(runtime, `return await tools.issues.list({})`)).toBe("list")
|
||||
expect(runtime.catalog.map((tool) => tool.path)).toEqual(["issues", "issues.list"])
|
||||
expect(runtime.catalog().map((tool) => tool.path)).toEqual(["issues", "issues.list"])
|
||||
})
|
||||
|
||||
test("a callable namespace enumerates its children", async () => {
|
||||
@@ -145,7 +145,7 @@ describe("tool input diagnostics", () => {
|
||||
|
||||
test("an empty-input tool advertises () and runs with zero arguments", async () => {
|
||||
const empty = CodeMode.make({ tools: { ping: echo("Ping", "pong") } })
|
||||
expect(empty.catalog[0]?.signature).toBe("tools.ping(): Promise<string>")
|
||||
expect(empty.catalog()[0]?.signature).toBe("tools.ping(): Promise<string>")
|
||||
expect(await value(empty, `return await tools.ping()`)).toBe("pong")
|
||||
})
|
||||
})
|
||||
@@ -160,7 +160,7 @@ describe("blocked member names on tool paths", () => {
|
||||
})
|
||||
|
||||
test("tools may use blocked member names because path segments never touch real properties", async () => {
|
||||
expect(runtime.catalog.map((tool) => tool.path)).toEqual(["issues.constructor", "nested.__proto__", "prototype"])
|
||||
expect(runtime.catalog().map((tool) => tool.path)).toEqual(["issues.constructor", "nested.__proto__", "prototype"])
|
||||
expect(await value(runtime, `return await tools.prototype({})`)).toBe("proto")
|
||||
expect(await value(runtime, `return await tools.issues.constructor({})`)).toBe("ctor")
|
||||
expect(await value(runtime, `return await tools["issues.constructor"]({})`)).toBe("ctor")
|
||||
@@ -172,7 +172,7 @@ describe("blocked member names on tool paths", () => {
|
||||
const poisoned = CodeMode.make({
|
||||
tools: { ns: { __proto__: echo("Hidden", "hidden"), real: echo("Real tool", "real") } },
|
||||
})
|
||||
expect(poisoned.catalog.map((tool) => tool.path)).toEqual(["ns.real"])
|
||||
expect(poisoned.catalog().map((tool) => tool.path)).toEqual(["ns.real"])
|
||||
expect(await value(poisoned, `return await tools.ns.real({})`)).toBe("real")
|
||||
})
|
||||
|
||||
@@ -221,7 +221,7 @@ describe("namespace metadata", () => {
|
||||
const runtime = CodeMode.make({ tools })
|
||||
|
||||
test("the wrapper does not add a segment to callable paths", async () => {
|
||||
expect(runtime.catalog.map((tool) => tool.path)).toEqual(["api.status", "api.users.list", "plain.read"])
|
||||
expect(runtime.catalog().map((tool) => tool.path)).toEqual(["api.status", "api.users.list", "plain.read"])
|
||||
expect(await value(runtime, `return await tools.api.users.list({})`)).toBe("users")
|
||||
})
|
||||
|
||||
@@ -260,8 +260,8 @@ describe("canonical path collisions", () => {
|
||||
tools: { "issues.list": echo("First", "first"), issues: { list: echo("Second", "second") } },
|
||||
})
|
||||
expect(await value(runtime, `return await tools.issues.list({})`)).toBe("second")
|
||||
expect(runtime.catalog).toHaveLength(1)
|
||||
expect(runtime.catalog[0]?.description).toBe("Second")
|
||||
expect(runtime.catalog()).toHaveLength(1)
|
||||
expect(runtime.catalog()[0]?.description).toBe("Second")
|
||||
})
|
||||
|
||||
test("overriding one path keeps sibling tools from both shapes", async () => {
|
||||
@@ -272,7 +272,7 @@ describe("canonical path collisions", () => {
|
||||
"issues.close": echo("Close issue", "closed"),
|
||||
},
|
||||
})
|
||||
expect(runtime.catalog.map((tool) => tool.path)).toEqual(["issues.close", "issues.get", "issues.list"])
|
||||
expect(runtime.catalog().map((tool) => tool.path)).toEqual(["issues.close", "issues.get", "issues.list"])
|
||||
expect(await value(runtime, `return await tools.issues.list({})`)).toBe("second")
|
||||
expect(await value(runtime, `return await tools.issues.get({})`)).toBe("got")
|
||||
expect(await value(runtime, `return await tools.issues.close({})`)).toBe("closed")
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
/**
|
||||
* Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75:
|
||||
* - test/language/statements/variable/S12.2_A1.js
|
||||
* - test/language/statements/variable/S12.2_A3.js
|
||||
* - test/language/statements/variable/S12.2_A6_T1.js
|
||||
* - test/language/statements/variable/S12.2_A7.js
|
||||
* - test/language/statements/variable/S12.2_A10.js
|
||||
* - test/language/statements/variable/S12.2_A12.js
|
||||
* - test/language/block-scope/shadowing/hoisting-var-declarations-out-of-blocks.js
|
||||
* - test/language/block-scope/shadowing/catch-parameter-shadowing-var-variable.js
|
||||
* - test/language/statements/for/head-var-bound-names-in-stmt.js
|
||||
* - test/language/statements/function/scope-paramsbody-var-open.js
|
||||
* - test/language/statements/function/scope-paramsbody-var-close.js
|
||||
*
|
||||
* Copyright 2009 the Sputnik authors. All rights reserved.
|
||||
* Copyright (C) 2011, 2016 the V8 project authors. All rights reserved.
|
||||
* Test262 portions are governed by the BSD license in LICENSE.test262.
|
||||
*
|
||||
* Files that observe `var` through `eval`, `this`, `delete`, or the global object (S12.2_A2, A5, A9,
|
||||
* A11, `scope-*-none.js`, `scope-param-elem-*.js`) have no analogue here and are not ported.
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { CodeMode } from "../src/index.js"
|
||||
|
||||
const value = async (code: string) => {
|
||||
const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} }))
|
||||
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
|
||||
return result.value
|
||||
}
|
||||
|
||||
describe("var hoisting Test262 parity", () => {
|
||||
test("test/language/statements/variable/S12.2_A1.js: use before declaration reads undefined", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
__x = __x
|
||||
__y = __x ? "good fellow" : "liar"
|
||||
__z = __z === __x ? 1 : 0
|
||||
let unknown
|
||||
try { __something__undefined = __something__undefined } catch (error) { unknown = error.name }
|
||||
const before = [__y, __z, unknown]
|
||||
var __x, __y = true, __z = __y ? "smeagol" : "golum"
|
||||
return [...before, __y, __z]
|
||||
`),
|
||||
).toEqual(["liar", 1, "ReferenceError", true, "smeagol"])
|
||||
})
|
||||
|
||||
test("test/language/statements/variable/S12.2_A3.js: nested functions redeclare or assign", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
var __var = "OUT"
|
||||
const inner = (function () {
|
||||
var __var = "IN"
|
||||
;(function () { __var = "INNER_SPACE" })()
|
||||
;(function () { var __var = "INNER_SUN" })()
|
||||
return __var
|
||||
})()
|
||||
const after = __var
|
||||
const assigned = (function () {
|
||||
__var = "IN"
|
||||
;(function () { __var = "INNERED" })()
|
||||
;(function () { var __var = "INNAGER" })()
|
||||
return __var
|
||||
})()
|
||||
return [inner, after, assigned, __var]
|
||||
`),
|
||||
).toEqual(["INNER_SPACE", "OUT", "INNERED", "INNERED"])
|
||||
})
|
||||
|
||||
test("test/language/statements/variable/S12.2_A6_T1.js: var inside try and catch is hoisted", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
intry__var = intry__var
|
||||
incatch__var = incatch__var
|
||||
try { var intry__var } catch (e) { var incatch__var }
|
||||
return [typeof intry__var, typeof incatch__var]
|
||||
`),
|
||||
).toEqual(["undefined", "undefined"])
|
||||
})
|
||||
|
||||
test("test/language/statements/variable/S12.2_A7.js: var after break inside for is hoisted", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
infor_var = infor_var
|
||||
for (;;) { break; var infor_var }
|
||||
return typeof infor_var
|
||||
`),
|
||||
).toBe("undefined")
|
||||
})
|
||||
|
||||
test("test/language/statements/variable/S12.2_A10.js: var in for head is hoisted", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
__ind = __ind
|
||||
for (var __ind; ; ) { break }
|
||||
return typeof __ind
|
||||
`),
|
||||
).toBe("undefined")
|
||||
})
|
||||
|
||||
test("test/language/statements/variable/S12.2_A12.js: var in do-while body is hoisted", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
x = x
|
||||
do var x; while (false)
|
||||
return typeof x
|
||||
`),
|
||||
).toBe("undefined")
|
||||
})
|
||||
|
||||
test("test/language/block-scope/shadowing/hoisting-var-declarations-out-of-blocks.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
function fn() {
|
||||
{ var x = 1; var y }
|
||||
return [x, typeof y]
|
||||
}
|
||||
return fn()
|
||||
`),
|
||||
).toEqual([1, "undefined"])
|
||||
})
|
||||
|
||||
test("test/language/block-scope/shadowing/catch-parameter-shadowing-var-variable.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
function fn() {
|
||||
var a = 1
|
||||
let caught
|
||||
try { throw "stuff3" } catch (a) { caught = a }
|
||||
return [caught, a]
|
||||
}
|
||||
return fn()
|
||||
`),
|
||||
).toEqual(["stuff3", 1])
|
||||
})
|
||||
|
||||
test("test/language/statements/for/head-var-bound-names-in-stmt.js: redeclaring the head var in the body", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
var iterCount = 0
|
||||
var first = true
|
||||
for (var x; first; first = false) {
|
||||
var x
|
||||
iterCount += 1
|
||||
}
|
||||
return iterCount
|
||||
`),
|
||||
).toBe(1)
|
||||
})
|
||||
|
||||
test("test/language/statements/function/scope-paramsbody-var-open.js: parameter defaults see the outer var", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
var x = "outside"
|
||||
var probeParams, probeBody
|
||||
function f(_ = probeParams = function () { return x }) {
|
||||
var x = "inside"
|
||||
probeBody = function () { return x }
|
||||
}
|
||||
f()
|
||||
return [probeParams(), probeBody()]
|
||||
`),
|
||||
).toEqual(["outside", "inside"])
|
||||
})
|
||||
|
||||
test("test/language/statements/function/scope-paramsbody-var-close.js: body var does not leak out", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
var probe
|
||||
function f(_ = null) {
|
||||
var x = "inside"
|
||||
probe = function () { return x }
|
||||
}
|
||||
f()
|
||||
var x = "outside"
|
||||
return [probe(), x]
|
||||
`),
|
||||
).toEqual(["inside", "outside"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("var semantics beyond Test262", () => {
|
||||
test("redeclaration and block-level var assign the one function-scoped binding", async () => {
|
||||
expect(await value(`var a = 1; var a = 2; { var a = 3 } return a`)).toBe(3)
|
||||
expect(await value(`var q = 1; { let q = 2 } return q`)).toBe(1)
|
||||
})
|
||||
|
||||
test("var loop counters are shared by closures, let counters are not", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const byVar = []
|
||||
for (var i = 0; i < 3; i++) byVar.push(() => i)
|
||||
const byLet = []
|
||||
for (let j = 0; j < 3; j++) byLet.push(() => j)
|
||||
return [byVar.map((f) => f()), byLet.map((f) => f())]
|
||||
`),
|
||||
).toEqual([
|
||||
[3, 3, 3],
|
||||
[0, 1, 2],
|
||||
])
|
||||
})
|
||||
|
||||
test("for...in and for...of var heads survive the loop", async () => {
|
||||
expect(await value(`for (var k in { a: 1 }) {} for (var [p, q] of [[1, 2]]) {} return [k, p, q]`)).toEqual([
|
||||
"a",
|
||||
1,
|
||||
2,
|
||||
])
|
||||
})
|
||||
|
||||
test("var and function declarations of the same name share a binding", async () => {
|
||||
expect(await value(`var fn = 1; function fn() {} return typeof fn`)).toBe("number")
|
||||
expect(await value(`function fn() {} var fn; return typeof fn`)).toBe("function")
|
||||
expect(await value(`function h() { var fn = 1; function fn() {} return typeof fn } return h()`)).toBe("number")
|
||||
})
|
||||
|
||||
test("a var named after a parameter keeps the argument until assigned", async () => {
|
||||
expect(await value(`function f(a) { var a; return a } return f(7)`)).toBe(7)
|
||||
expect(await value(`function f(a) { var a = 2; return a } return f(7)`)).toBe(2)
|
||||
})
|
||||
|
||||
test("var does not hoist across function boundaries", async () => {
|
||||
expect(await value(`return [typeof b, (() => { var b = 1; return b })()]; var b`)).toEqual(["undefined", 1])
|
||||
expect(
|
||||
await value(
|
||||
`function outer() { var o = 1; function inner() { var o = 2; return o } return [inner(), o] } return outer()`,
|
||||
),
|
||||
).toEqual([2, 1])
|
||||
})
|
||||
|
||||
test("switch cases, labels, and generators hoist var", async () => {
|
||||
expect(await value(`switch (1) { case 1: var s = 9 } label: { var lb = 1 } return [s, lb]`)).toEqual([9, 1])
|
||||
expect(await value(`function* gen() { var t = 1; yield t; var t = 2; yield t } return [...gen()]`)).toEqual([1, 2])
|
||||
})
|
||||
})
|
||||
|
||||
describe("switch case function hoisting", () => {
|
||||
test("function declarations are visible across all cases before their statement runs", async () => {
|
||||
expect(await value(`switch (1) { case 1: return foo(); function foo() { return "hoisted" } }`)).toBe("hoisted")
|
||||
expect(await value(`switch (2) { case 1: function foo() { return "a" } break; case 2: return foo() }`)).toBe("a")
|
||||
})
|
||||
})
|
||||
@@ -1,169 +0,0 @@
|
||||
/**
|
||||
* Portions adapted from web-platform-tests at revision 863077959ca8c1a7ceecfbe2534b75d2527b9013:
|
||||
* - html/webappapis/atob/base64.any.js (btoa reference encoder, input list, and atob WebIDL cases)
|
||||
* - fetch/data-urls/resources/base64.json (copied to fixtures/wpt-base64.json)
|
||||
* - WebCryptoAPI/randomUUID.https.any.js
|
||||
*
|
||||
* Copyright © web-platform-tests contributors. Governed by the 3-Clause BSD license in LICENSE.wpt.
|
||||
*
|
||||
* `assert_throws_dom("InvalidCharacterError", …)` becomes a check on `error.name`: CodeMode has no
|
||||
* DOMException, so the name is carried on a plain Error.
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { CodeMode } from "../src/index.js"
|
||||
|
||||
const base64Cases = (await Bun.file(new URL("./fixtures/wpt-base64.json", import.meta.url)).json()) as Array<
|
||||
[string, Array<number> | null]
|
||||
>
|
||||
|
||||
const value = async (code: string) => {
|
||||
const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} }))
|
||||
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
|
||||
return result.value
|
||||
}
|
||||
|
||||
// The reference encoder from base64.any.js, run inside the interpreter so btoa is checked against
|
||||
// an independent implementation rather than against the host's btoa.
|
||||
const referenceEncoder = `
|
||||
function btoaLookup(idx) {
|
||||
if (idx < 26) return String.fromCharCode(idx + "A".charCodeAt(0))
|
||||
if (idx < 52) return String.fromCharCode(idx - 26 + "a".charCodeAt(0))
|
||||
if (idx < 62) return String.fromCharCode(idx - 52 + "0".charCodeAt(0))
|
||||
if (idx == 62) return "+"
|
||||
if (idx == 63) return "/"
|
||||
}
|
||||
function mybtoa(s) {
|
||||
s = String(s)
|
||||
for (var i = 0; i < s.length; i++) if (s.charCodeAt(i) > 255) return "INVALID_CHARACTER_ERR"
|
||||
var out = ""
|
||||
for (var i = 0; i < s.length; i += 3) {
|
||||
var groupsOfSix = [undefined, undefined, undefined, undefined]
|
||||
groupsOfSix[0] = s.charCodeAt(i) >> 2
|
||||
groupsOfSix[1] = (s.charCodeAt(i) & 0x03) << 4
|
||||
if (s.length > i + 1) {
|
||||
groupsOfSix[1] |= s.charCodeAt(i + 1) >> 4
|
||||
groupsOfSix[2] = (s.charCodeAt(i + 1) & 0x0f) << 2
|
||||
}
|
||||
if (s.length > i + 2) {
|
||||
groupsOfSix[2] |= s.charCodeAt(i + 2) >> 6
|
||||
groupsOfSix[3] = s.charCodeAt(i + 2) & 0x3f
|
||||
}
|
||||
for (var j = 0; j < groupsOfSix.length; j++) {
|
||||
out += typeof groupsOfSix[j] == "undefined" ? "=" : btoaLookup(groupsOfSix[j])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
function testBtoa(input) {
|
||||
var expected = mybtoa(input)
|
||||
if (expected === "INVALID_CHARACTER_ERR") {
|
||||
try { btoa(input) } catch (error) { return error.name === "InvalidCharacterError" ? "ok" : error.name }
|
||||
return "did not throw"
|
||||
}
|
||||
if (btoa(input) !== expected) return "btoa mismatch"
|
||||
if (atob(btoa(input)) !== String(input)) return "roundtrip mismatch"
|
||||
return "ok"
|
||||
}
|
||||
`
|
||||
|
||||
describe("btoa WPT parity (html/webappapis/atob/base64.any.js)", () => {
|
||||
test("every input encodes like the reference encoder and round-trips through atob", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
${referenceEncoder}
|
||||
var tests = ["עברית", "", "ab", "abc", "abcd", "abcde", "\\xff\\xff\\xc0", "\\0a", "a\\0b",
|
||||
undefined, null, 7, 12, 1.5, true, false, NaN, +Infinity, -Infinity, 0, -0]
|
||||
for (var i = 0; i < 258; i++) tests.push(String.fromCharCode(i))
|
||||
tests.push(String.fromCharCode(10000), String.fromCharCode(65534), String.fromCharCode(65535))
|
||||
tests.push(String.fromCharCode(0xd800, 0xdc00))
|
||||
var everything = ""
|
||||
for (var i = 0; i < 256; i++) everything += String.fromCharCode(i)
|
||||
tests.push(everything)
|
||||
return tests.map(testBtoa).filter((outcome) => outcome !== "ok")
|
||||
`),
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("atob WPT parity (fetch/data-urls/resources/base64.json)", () => {
|
||||
const idlCases: Array<[unknown, Array<number> | null]> = [
|
||||
[undefined, null],
|
||||
[null, [158, 233, 101]],
|
||||
[7, null],
|
||||
[12, [215]],
|
||||
[1.5, null],
|
||||
[true, [182, 187]],
|
||||
[false, null],
|
||||
[NaN, [53, 163]],
|
||||
[Infinity, [34, 119, 226, 158, 43, 114]],
|
||||
[-Infinity, null],
|
||||
[0, null],
|
||||
[-0, null],
|
||||
]
|
||||
|
||||
test(`${base64Cases.length} forgiving-base64 inputs decode to the expected bytes or throw InvalidCharacterError`, async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const cases = ${JSON.stringify(base64Cases)}
|
||||
return cases.flatMap(([input, output]) => {
|
||||
try {
|
||||
const result = atob(input)
|
||||
if (output === null) return [[input, "expected throw"]]
|
||||
const bytes = Array.from({ length: result.length }, (_, i) => result.charCodeAt(i))
|
||||
return JSON.stringify(bytes) === JSON.stringify(output) ? [] : [[input, bytes]]
|
||||
} catch (error) {
|
||||
return output === null && error.name === "InvalidCharacterError" ? [] : [[input, error.name]]
|
||||
}
|
||||
})
|
||||
`),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test("WebIDL argument conversion stringifies non-string inputs", async () => {
|
||||
const literal = (input: unknown) =>
|
||||
Object.is(input, -0)
|
||||
? "-0"
|
||||
: typeof input === "number" || input === undefined
|
||||
? String(input)
|
||||
: JSON.stringify(input)
|
||||
expect(
|
||||
await value(`
|
||||
const cases = [${idlCases.map(([input, output]) => `[${literal(input)}, ${JSON.stringify(output)}]`).join(",")}]
|
||||
return cases.flatMap(([input, output]) => {
|
||||
try {
|
||||
const result = atob(input)
|
||||
if (output === null) return [[String(input), "expected throw"]]
|
||||
// The source loop checks only the listed prefix of the decoded bytes.
|
||||
const bytes = output.map((_, i) => result.charCodeAt(i))
|
||||
return JSON.stringify(bytes) === JSON.stringify(output) ? [] : [[String(input), bytes]]
|
||||
} catch (error) {
|
||||
return output === null && error.name === "InvalidCharacterError" ? [] : [[String(input), error.name]]
|
||||
}
|
||||
})
|
||||
`),
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("crypto.randomUUID WPT parity (WebCryptoAPI/randomUUID.https.any.js)", () => {
|
||||
test("namespace format, version, and variant bits over 256 iterations without collision", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const uuids = new Set()
|
||||
const randomUUID = () => {
|
||||
const uuid = crypto.randomUUID()
|
||||
if (uuids.has(uuid)) throw new Error("uuid collision " + uuid)
|
||||
uuids.add(uuid)
|
||||
return uuid
|
||||
}
|
||||
const UUIDRegex = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/
|
||||
let format = true, version = true, variant = true
|
||||
for (let i = 0; i < 256; i++) format = format && UUIDRegex.test(randomUUID())
|
||||
for (let i = 0; i < 256; i++) version = version && (parseInt(randomUUID().split("-")[2].slice(0, 2), 16) & 0b11110000) === 0b01000000
|
||||
for (let i = 0; i < 256; i++) variant = variant && (parseInt(randomUUID().split("-")[3].slice(0, 2), 16) & 0b11000000) === 0b10000000
|
||||
return [format, version, variant, uuids.size]
|
||||
`),
|
||||
).toEqual([true, true, true, 768])
|
||||
})
|
||||
})
|
||||
@@ -99,17 +99,6 @@ export const create = (
|
||||
const outputFileParts = outputFiles(content)
|
||||
if (outputFileParts.length > 0)
|
||||
yield* Ref.update(files, (items) => [...items, { index, files: outputFileParts }])
|
||||
// Agents assume JSON returned as text is already an object. mcp.ts folds text content into
|
||||
// `output` as a string, so parse it for MCP tools without an output schema (registered as `{}`).
|
||||
const noSchema = tool.output !== undefined && Object.keys(tool.output).length === 0
|
||||
if (typeof executed.output === "string" && noSchema) {
|
||||
const trimmed = executed.output.trimStart()
|
||||
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
||||
try {
|
||||
return JSON.parse(executed.output)
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
if (executed.output !== undefined) return executed.output
|
||||
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
|
||||
return text === "" ? null : text
|
||||
@@ -177,7 +166,7 @@ export const catalog = (inventory: Inventory) => {
|
||||
)
|
||||
const root: CatalogNode = { children: new Map() }
|
||||
for (const namespace of inventory.namespaces?.values() ?? []) getNode(root, namespace.name).namespace = namespace
|
||||
for (const tool of runtime(inventory, () => Effect.fail(toolError("Execute context is unavailable"))).catalog)
|
||||
for (const tool of runtime(inventory, () => Effect.fail(toolError("Execute context is unavailable"))).catalog())
|
||||
getNode(root, tool.path).tool = {
|
||||
type: "tool",
|
||||
name: tool.path.split(".").at(-1) ?? tool.path,
|
||||
|
||||
@@ -3,7 +3,7 @@ export * as ConfigInstructionPlugin from "./instruction.js"
|
||||
import { define } from "@opencode/plugin/effect/plugin"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { dirname, join } from "path"
|
||||
import { dirname, join, relative } from "path"
|
||||
import { Effect, PubSub, Semaphore, Stream } from "effect"
|
||||
import { Watcher } from "../../filesystem/watcher.js"
|
||||
import { InstructionDiscovery } from "../../instruction-discovery.js"
|
||||
@@ -34,6 +34,8 @@ export const Plugin = define({
|
||||
const home = yield* fs.resolve(global.home)
|
||||
const project = discovery.project && FSUtil.contains(root, start)
|
||||
const stop = FSUtil.contains(home, start) ? home : root
|
||||
const ancestors = project ? ancestorDirectories(start, stop) : []
|
||||
const boundary = ancestors.at(-1) ?? stop
|
||||
const globalFile = yield* fs.resolve(join(global.config, "AGENTS.md"))
|
||||
const loaded: { current: Loaded } = { current: { type: "available", files: [] } }
|
||||
|
||||
@@ -43,7 +45,7 @@ export const Plugin = define({
|
||||
const candidates = [
|
||||
...(discovery.global ? [globalFile] : []),
|
||||
...(project
|
||||
? ancestorDirectories(start, stop)
|
||||
? ancestors
|
||||
.map((directory) => join(directory, "AGENTS.md"))
|
||||
.filter((file) => discovery.global || file !== globalFile)
|
||||
: []),
|
||||
@@ -67,7 +69,10 @@ export const Plugin = define({
|
||||
|
||||
const projectSource = Effect.fn("ConfigInstructionPlugin.projectSource")(function* () {
|
||||
if (!project) return []
|
||||
const walked = yield* Effect.forEach(yield* fs.up({ targets: ["AGENTS.md"], start, stop }), fs.resolve)
|
||||
const walked = yield* Effect.forEach(
|
||||
yield* fs.up({ targets: ["AGENTS.md"], start, stop: boundary }),
|
||||
fs.resolve,
|
||||
)
|
||||
const discovered = new Set(walked.filter((file) => discovery.global || file !== globalFile))
|
||||
const files = yield* Effect.forEach(discovered, read, { concurrency: "unbounded" })
|
||||
if (files.some((file) => file === undefined)) return Instructions.unavailable
|
||||
@@ -131,6 +136,9 @@ export const Plugin = define({
|
||||
})
|
||||
|
||||
function ancestorDirectories(start: string, stop: string): string[] {
|
||||
if (start === stop) return [start]
|
||||
return [start, ...ancestorDirectories(dirname(start), stop)]
|
||||
const result = [start]
|
||||
if (relative(start, stop) === "") return result
|
||||
const parent = dirname(start)
|
||||
if (parent === start) throw new Error(`Instruction boundary ${stop} is not an ancestor of ${start}`)
|
||||
return [...result, ...ancestorDirectories(parent, stop)]
|
||||
}
|
||||
|
||||
@@ -101,7 +101,6 @@ export const XAIPlugin = define({
|
||||
for (const model of provider.models.values()) {
|
||||
catalog.model.update(providerID, model.id, (draft) => {
|
||||
draft.capabilities.responsesWebsockets = true
|
||||
draft.websocket = true
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -446,6 +446,7 @@ export const layer = Layer.effect(
|
||||
const compactionRequest = (
|
||||
input: ExecuteInput,
|
||||
messages: readonly SessionMessage.Info[],
|
||||
prompt: Message[],
|
||||
webSocket?: "session",
|
||||
) => {
|
||||
const context = input.context
|
||||
@@ -465,6 +466,7 @@ export const layer = Layer.effect(
|
||||
messages: [
|
||||
...transcript.messages,
|
||||
...(input.instructionUpdate ? [Message.system(input.instructionUpdate)] : []),
|
||||
...prompt,
|
||||
],
|
||||
webSocket,
|
||||
})
|
||||
@@ -484,7 +486,7 @@ export const layer = Layer.effect(
|
||||
inputID: input.inputID,
|
||||
error: { type: "provider.unsupported-operation", message },
|
||||
})
|
||||
const prepared = yield* compactionRequest(input, context.messages, "session")
|
||||
const prepared = yield* compactionRequest(input, context.messages, [], "session")
|
||||
if (prepared.event.result) {
|
||||
yield* started(input, "")
|
||||
return yield* supplied(input, prepared.event.result, "")
|
||||
@@ -601,12 +603,10 @@ export const layer = Layer.effect(
|
||||
)
|
||||
// Checkpoints from the previous template ran far longer than this one asks for; its catch-all heading identifies them.
|
||||
const legacy = previous?.summary.includes(LEGACY_HEADING) ?? false
|
||||
const prepared = yield* compactionRequest(input, history.messages)
|
||||
const prepared = yield* compactionRequest(input, history.messages, [
|
||||
Message.user(buildPrompt(previous !== undefined, legacy)),
|
||||
])
|
||||
if (prepared.event.result) return yield* supplied(input, prepared.event.result, history.recent)
|
||||
// Hooks see the transcript alone; the summary prompt is appended after they run.
|
||||
const first = LLMRequest.update(prepared.request, {
|
||||
messages: [...prepared.request.messages, Message.user(buildPrompt(previous !== undefined, legacy))],
|
||||
})
|
||||
// Both requests share the retry allowance; rejected output never enters the reminder request.
|
||||
const transient = SessionRunnerRetry.transient(yield* SessionRunnerRetry.policy(context.session.id), {
|
||||
agent: context.agent.id,
|
||||
@@ -614,10 +614,10 @@ export const layer = Layer.effect(
|
||||
hook: prepared.retry,
|
||||
})
|
||||
for (const request of [
|
||||
first,
|
||||
LLMRequest.update(first, {
|
||||
prepared.request,
|
||||
LLMRequest.update(prepared.request, {
|
||||
messages: [
|
||||
...first.messages,
|
||||
...prepared.request.messages,
|
||||
Message.user(
|
||||
"The previous response did not fill in the required summary template. Do not call tools. Return the summary as text using the exact section headings from the template.",
|
||||
),
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { CodeModeTool } from "@opencode/core/codemode/tool"
|
||||
import type { Context, Info, Result } from "@opencode/schema/tool"
|
||||
import { Effect, Schema } from "effect"
|
||||
|
||||
// Runs `tools.probe({})` and returns what the program saw as [typeof value, value].
|
||||
const run = async (output: Info["output"], result: Result) => {
|
||||
const tool: Info = {
|
||||
name: "probe",
|
||||
description: "probe",
|
||||
input: Schema.Struct({}),
|
||||
execute: () => Effect.succeed(result),
|
||||
...(output === undefined ? {} : { output }),
|
||||
}
|
||||
const execute = CodeModeTool.create({ tools: new Map([["probe", tool]]) }, () => Effect.succeed(result))
|
||||
const context = { progress: () => Effect.void } as unknown as Context
|
||||
const executed = await Effect.runPromise(
|
||||
execute.execute({ code: "const r = await tools.probe({}); return [typeof r, r]" }, context),
|
||||
)
|
||||
return JSON.parse(executed.output.output)
|
||||
}
|
||||
|
||||
// An MCP-shaped result: text content, output mirrors the text, and `{}` for a missing outputSchema.
|
||||
const mcp = (text: string) => ({ output: text, content: [{ type: "text" as const, text }] })
|
||||
|
||||
describe("code mode parses JSON text results from MCP tools without an outputSchema", () => {
|
||||
test("one JSON object or array text block becomes a value", async () => {
|
||||
expect(await run({}, mcp('{"issues":[{"id":1}]}'))).toEqual(["object", { issues: [{ id: 1 }] }])
|
||||
expect(await run({}, mcp(" [1, 2]"))).toEqual(["object", [1, 2]])
|
||||
})
|
||||
|
||||
test("structuredContent is untouched", async () => {
|
||||
expect(await run({}, { output: { a: 1 }, content: [{ type: "text", text: "ignored" }] })).toEqual([
|
||||
"object",
|
||||
{ a: 1 },
|
||||
])
|
||||
})
|
||||
|
||||
test("a declared output schema is never second-guessed", async () => {
|
||||
expect(await run({ type: "string" }, mcp('{"a":1}'))).toEqual(["string", '{"a":1}'])
|
||||
expect(await run(Schema.String, mcp('{"a":1}'))).toEqual(["string", '{"a":1}'])
|
||||
})
|
||||
|
||||
test("tools without any output schema keep their advertised string result", async () => {
|
||||
expect(await run(undefined, { content: [{ type: "text", text: '{"a":1}' }] })).toEqual(["string", '{"a":1}'])
|
||||
})
|
||||
|
||||
test("primitives and prose stay strings", async () => {
|
||||
expect(await run({}, mcp("42"))).toEqual(["string", "42"])
|
||||
expect(await run({}, mcp("null"))).toEqual(["string", "null"])
|
||||
expect(await run({}, mcp("[INFO] started"))).toEqual(["string", "[INFO] started"])
|
||||
expect(await run({}, mcp("{not json"))).toEqual(["string", "{not json"])
|
||||
})
|
||||
|
||||
test("multiple text blocks are joined, not parsed", async () => {
|
||||
const content = [
|
||||
{ type: "text" as const, text: "Result:" },
|
||||
{ type: "text" as const, text: '{"a":1}' },
|
||||
]
|
||||
expect(await run({}, { output: 'Result:\n{"a":1}', content })).toEqual(["string", 'Result:\n{"a":1}'])
|
||||
})
|
||||
})
|
||||
@@ -69,7 +69,7 @@ describe("XAIPlugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("enables xAI Responses WebSockets", () =>
|
||||
it.effect("keeps xAI Responses WebSockets opt-in", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = Provider.ID.make("xai")
|
||||
@@ -84,7 +84,7 @@ describe("XAIPlugin", () => {
|
||||
|
||||
const model = yield* catalog.model.get(providerID, Model.ID.make("grok-4.6"))
|
||||
expect(model?.capabilities.responsesWebsockets).toBe(true)
|
||||
expect(model?.websocket).toBe(true)
|
||||
expect(model?.websocket).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -358,14 +358,6 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||
}
|
||||
const session = yield* insertSession(sessionID, { parent_id: parentID })
|
||||
const modelRequests = yield* SessionModelRequest.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
let hooked = 0
|
||||
yield* hooks.register("session", "compaction", (event) =>
|
||||
Effect.sync(() => {
|
||||
hooked = event.messages.length
|
||||
expect(JSON.stringify(event.messages)).not.toContain("Summarize only what")
|
||||
}),
|
||||
)
|
||||
const messages = [
|
||||
userMessage,
|
||||
SessionMessage.Shell.make({
|
||||
@@ -418,8 +410,6 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||
expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.")
|
||||
expect(JSON.stringify(requests[0]?.messages)).toContain("Use Effect services and generators.")
|
||||
expect(JSON.stringify(requests[0]?.messages)).toContain("User shell pwd completed: /project")
|
||||
expect(requests[0]?.messages).toHaveLength(hooked + 1)
|
||||
expect(JSON.stringify(requests[0]?.messages.at(-1))).toContain("Summarize only what")
|
||||
expect(JSON.stringify(requests[0]?.messages)).not.toContain("display-only-output")
|
||||
// The compaction message carries its own request usage so clients can show what compacting cost.
|
||||
expect(yield* store.context(sessionID)).toMatchObject([
|
||||
|
||||
@@ -1944,16 +1944,7 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(yield* entries.list(sessionID)).toEqual([{ key: "nullable", value: null }])
|
||||
})
|
||||
|
||||
scenario("accepts API instruction entries up to 256 KiB", function* () {
|
||||
const entries = yield* InstructionEntry.Service
|
||||
const value = "x".repeat(InstructionEntry.MaxValueBytes - 2)
|
||||
|
||||
yield* entries.put({ sessionID, key: "large", value })
|
||||
|
||||
expect(yield* entries.list(sessionID)).toEqual([{ key: "large", value }])
|
||||
})
|
||||
|
||||
scenario("rejects API instruction entries larger than 256 KiB", function* () {
|
||||
scenario("rejects API instruction entries larger than 8KB", function* () {
|
||||
const entries = yield* InstructionEntry.Service
|
||||
|
||||
const exit = yield* entries
|
||||
|
||||
@@ -27,7 +27,7 @@ export const Snapshot = Schema.Array(
|
||||
).annotate({ identifier: "InstructionEntry.Snapshot" })
|
||||
export type Snapshot = typeof Snapshot.Type
|
||||
|
||||
export const MaxValueBytes = 256 * 1024
|
||||
export const MaxValueBytes = 8 * 1024
|
||||
|
||||
export class ValueTooLargeError extends Schema.TaggedError<ValueTooLargeError>()(
|
||||
"InstructionEntryValueTooLargeError",
|
||||
|
||||
@@ -427,17 +427,6 @@ export function SessionCompactionMessage(props: { message: SessionMessageCompact
|
||||
<div class="py-2">
|
||||
<TimelineSeparator label={i18n.t("ui.messagePart.compaction.started")} />
|
||||
</div>
|
||||
<Show when={summary().trim()}>
|
||||
<div data-component="text-part" data-timeline-part-id={props.message.id}>
|
||||
<div data-slot="text-part-body">
|
||||
<PacedMarkdown
|
||||
text={summary()}
|
||||
cacheKey={props.message.id}
|
||||
streaming={props.message.status === "running"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={props.message.status === "running"}>
|
||||
<div role="status" class="py-2">
|
||||
<BasicTool
|
||||
@@ -449,6 +438,17 @@ export function SessionCompactionMessage(props: { message: SessionMessageCompact
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={summary().trim()}>
|
||||
<div data-component="text-part" data-timeline-part-id={props.message.id}>
|
||||
<div data-slot="text-part-body">
|
||||
<PacedMarkdown
|
||||
text={summary()}
|
||||
cacheKey={props.message.id}
|
||||
streaming={props.message.status === "running"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={props.message.status !== "running"}>
|
||||
<div class="py-2">
|
||||
<TimelineSeparator label={label()} />
|
||||
|
||||
@@ -51,8 +51,7 @@ export function createClipboardAdapter(clipboard: CoreClipboardService): OwnedCl
|
||||
throw new Error(`Unexpected clipboard MIME type: ${result.representation.mimeType}`)
|
||||
},
|
||||
async write(text) {
|
||||
// OpenTUI rejects NUL before any destination; host clipboard text cannot contain it.
|
||||
const result = await clipboard.writeText(text.replaceAll("\0", ""), {
|
||||
const result = await clipboard.writeText(text, {
|
||||
destination: "all-available",
|
||||
selection: "clipboard",
|
||||
})
|
||||
|
||||
@@ -169,12 +169,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
: members.some((id) => (data.session.form.list(id)?.length ?? 0) > 0)
|
||||
? ("question" as const)
|
||||
: (false as const),
|
||||
// Parked synthetic context (user shells, plan reminders) stays pending without execution; only work counts as busy.
|
||||
busy: members.some(
|
||||
(id) =>
|
||||
data.session.status(id) === "running" ||
|
||||
data.session.pending.list(id).some((item) => item.type !== "synthetic"),
|
||||
),
|
||||
busy: members.some((id) => data.session.status(id) === "running" || data.session.pending.list(id).length > 0),
|
||||
renaming: data.session.title.pending(session),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,19 +102,6 @@ test("uses all available routes but skips the process host remotely", async () =
|
||||
expect(writes).toEqual({ host: 0, terminal: 1 })
|
||||
})
|
||||
|
||||
test("removes NUL characters before writing", async () => {
|
||||
const writes: string[] = []
|
||||
const clipboard = createClipboardAdapter(
|
||||
coreClipboard({
|
||||
onWrite: (text) => writes.push(text),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(await clipboard.write("before\0after")).toBeUndefined()
|
||||
expect(await clipboard.write("clean")).toBeUndefined()
|
||||
expect(writes).toEqual(["beforeafter", "clean"])
|
||||
})
|
||||
|
||||
test("rejects only when no clipboard route accepted the write", async () => {
|
||||
const writes: [string, ClipboardWriteOptions][] = []
|
||||
const failure = new Error("native clipboard failed")
|
||||
|
||||
@@ -1204,9 +1204,8 @@ kind of request a session issues has its own hook, so a plugin can treat the age
|
||||
differently:
|
||||
|
||||
- `context` runs for the agent loop, including tool-driven continuations.
|
||||
- `compaction` runs for checkpoint summaries. `messages` is the transcript being summarized; OpenCode appends its
|
||||
summary prompt after hooks run. Set `result` to record the compaction yourself and skip the model call; it takes the
|
||||
same fields as a completed compaction message.
|
||||
- `compaction` runs for checkpoint summaries. Set `result` to record the compaction yourself and skip the model
|
||||
call; it takes the same fields as a completed compaction message.
|
||||
- `generate` runs for transient `ctx.session.generate` calls.
|
||||
- `title` runs for title generation. It has no `agent` or `tools`. Set `result` to supply the title yourself.
|
||||
|
||||
|
||||
@@ -110,11 +110,10 @@ role for other Foundry models. If a request fails because the token belongs to a
|
||||
|
||||
## WebSocket transport
|
||||
|
||||
OpenAI, xAI, and supported Azure Responses models keep one WebSocket connection open per session and send each step
|
||||
over it instead of opening a new HTTP request. While the request prefix is unchanged, consecutive steps only transmit
|
||||
what was added since the previous response, which cuts upload volume on long sessions. OpenAI provider compaction runs
|
||||
over the same connection. xAI continues a chain only from stored responses, so with its default `store: false` each step
|
||||
is sent in full over the reused connection.
|
||||
OpenAI and supported Azure Responses models keep one WebSocket connection open per session and send each step over it
|
||||
instead of opening a new HTTP request. While the request prefix is unchanged, consecutive steps only transmit what was
|
||||
added since the previous response, which cuts upload volume on long sessions. Provider compaction runs over the same
|
||||
connection.
|
||||
|
||||
The connection is transparent. When the provider closes the socket, the next step reconnects; when a connection cannot
|
||||
be opened at all, the session continues over HTTP. Plugins that register `http.request` or `http.response` hooks for a
|
||||
|
||||
Reference in New Issue
Block a user