Compare commits

...
30 Commits
Author SHA1 Message Date
Aiden Cline e98c5ac0ec fix(merman): support & node groups in flowchart statements
Mermaid flowcharts allow `A & B --> C` to declare fan-in and fan-out in one statement. The TUI parser rejected these lines as unsupported syntax, so any diagram using the shorthand silently fell back to a plain code block.

Split each chain position on `&` at bracket depth zero and outside quotes, then create the cartesian product of edges between adjacent groups. Bare node statements accept the same grouping.
2026-09-10 22:01:02 -05:00
James Long 1c723c56fa feat(tui): add recursive grouping tree (#48394) 2026-09-10 21:40:23 -04:00
Aiden Cline 181428a2f3 fix(codemode): use Bun's wording for the missing atob/btoa argument (#48381) 2026-09-10 20:08:36 -05:00
Dax Raad 9b1891fb7e docs(cli): expand provider connections 2026-09-10 20:43:17 -04:00
Dax Raad d4bf78b348 docs: add websearch guides 2026-09-10 20:30:58 -04:00
Dax Raad d4ceffe787 docs: refresh v2 guides 2026-09-10 20:12:08 -04:00
Aiden Cline 872e38055e fix(ai): normalize flat Responses stream errors (#48376) 2026-09-10 18:23:32 -05:00
James Long 0c1dfa9186 refactor(tui): extract shared session rendering primitives (#48393) 2026-09-10 18:07:14 -04:00
Dax Raad 8f4d706647 feat(cli): add command docs and simplify session list 2026-09-10 16:27:12 -04:00
Aiden Cline 929374cdfd feat(core): parse JSON text results from MCP tools without an output schema (#48357) 2026-09-10 15:20:13 -05:00
opencode-agent[bot] cfa5ba700e fix(stats): canonicalize DeepSeek Flash usage (#48373) 2026-09-10 13:58:51 -05:00
Shoubhit Dash 45a2ed9a97 feat(core): per-session permission rules (#48351) 2026-09-10 22:30:53 +05:30
opencode-agent[bot]andnexxeln f6333546f8 fix(tui): disambiguate plugin actions (#48354)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-09-10 22:10:01 +05:30
opencode-agent[bot]andnexxeln 9e153ce7b3 fix(core): raise instruction entry limit (#48350)
Co-authored-by: nexxeln <nexxeln@users.noreply.github.com>
2026-09-10 20:59:38 +05:30
Simon Klee eb357f17cf fix(tui): strip NUL characters before clipboard writes (#48337) 2026-09-10 15:47:03 +02:00
Shoubhit Dash 573d76933f fix(ai): make xAI Responses websockets work and enable them (#48318) 2026-09-10 17:27:43 +05:30
usrnk1andBrendonovich bb8194395a feat(desktop): respect follow-up behavior for slash commands (#48169)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-10 19:24:23 +08:00
opencode-agent[bot]andHona 2e8ed86658 fix(app): move compaction status below summary (#48262)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-09-10 18:27:08 +08:00
Brendan Allan 2695607fbc fix(app): stabilize optimistic prompt position (#46723) 2026-09-10 17:49:55 +08:00
Aiden Cline f3ef84556a feat(codemode): add atob, btoa, and crypto.randomUUID (#48290) 2026-09-10 01:21:41 -05:00
Aiden Cline 08ff21179c fix(codemode): hoist switch-case functions and memoize var names (#48287) 2026-09-10 00:42:08 -05:00
Aiden Cline 98a36fb1a4 feat(codemode): hoist var to function scope (#48284) 2026-09-10 00:27:58 -05:00
Aiden Cline e22cd0a585 refactor(codemode): collapse repeated loop, promise, and Object shapes (#48283) 2026-09-10 00:02:58 -05:00
Aiden Cline 8475783700 fix(tui): ignore parked synthetic inbox for tab busy (#48273) 2026-09-09 23:31:16 -05:00
Aiden Cline 1452aadc87 feat(codemode): add substr, well-formed, and Date string methods (#48275) 2026-09-09 23:29:34 -05:00
Aiden Cline 1417976257 fix(core): run compaction hooks before appending the summary prompt (#48276) 2026-09-09 22:54:00 -05:00
Aiden Cline 5ec7dd968c fix(codemode): coerce enumeration sources like JS ToObject (#48257) 2026-09-09 22:24:55 -05:00
Aiden ClineandAljosha Friemann 43fb543e3b fix(core): request summarized adaptive thinking (#48271)
Co-authored-by: Aljosha Friemann <1730315+afriemann@users.noreply.github.com>
2026-09-09 22:09:57 -05:00
Aiden Cline ac7f3c5ece fix(core): route Mantle GPT-OSS models through Chat (#48251) 2026-09-09 21:27:15 -05:00
Aiden Cline 3edbc88225 feat(plugin): add session compaction and generate hooks (#48212) 2026-09-09 21:03:27 -05:00
140 changed files with 6828 additions and 2343 deletions
@@ -18,7 +18,6 @@ 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
@@ -27,6 +26,7 @@ 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* decodeEvent(frame).pipe(
const event = yield* OpenResponses.decodeChannelEvent(frame).pipe(
Effect.mapError((cause) =>
ProviderShared.eventError(options.id, `Invalid ${options.name} WebSocket event`, frame, cause),
),
@@ -163,6 +163,7 @@ export const transport = <Body>(options: Options): Transport<Body, Prepared, str
request: create.request,
message: create.message,
base,
continuation: options.continuation,
}),
}
})
@@ -6,7 +6,6 @@ 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
@@ -15,12 +14,19 @@ 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 => {
@@ -127,22 +133,26 @@ 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)
const delta = previous ? incremental(request, previous) : undefined
if (!previous || !delta) return { message: ProviderShared.encodeJson(request), mode: "full" as const }
// 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 }
return {
message: ProviderShared.encodeJson({ ...request, input: delta, previous_response_id: previous.responseID }),
message: ProviderShared.encodeJson({ ...fields, input: delta, previous_response_id: previous.responseID }),
mode: "incremental" as const,
}
}),
observe: (create, frame) =>
Effect.gen(function* () {
const event = yield* decodeEvent(frame).pipe(
const event = yield* OpenResponses.decodeChannelEvent(frame).pipe(
Effect.mapError((cause) =>
ProviderShared.eventError(input.id, `Invalid ${input.name} WebSocket event`, frame, cause),
),
@@ -195,4 +205,4 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
}
}
export const OpenResponsesContinuation = { driver } as const
export * as OpenResponsesContinuation from "./open-responses-continuation.js"
+32 -4
View File
@@ -1,4 +1,4 @@
import { Effect, Option, Schema } from "effect"
import { Effect, Option, Schema, SchemaGetter } from "effect"
import type { Content } from "@opencode/schema/tool"
import { HttpTransport } from "../route/transport/index.js"
import { Protocol } from "../route/protocol.js"
@@ -325,9 +325,8 @@ export const StreamItem = Schema.StructWithRest(
export type StreamItem = Schema.Schema.Type<typeof StreamItem>
export type OutputItem = StreamItem & { readonly id: string }
// The Responses schema puts streaming error details at the top level and
// response failures under `response.error`. WebSocket failures use an
// event-level `error` envelope, so accept all three shapes here.
// Responses-compatible providers put streaming error details at the top level or
// under `error`, and response failures under `response.error`. Accept all three shapes.
// https://www.openresponses.org/specification
const OpenResponsesErrorPayload = Schema.Struct({
type: optionalNull(Schema.String),
@@ -401,10 +400,39 @@ export const Event = Schema.StructWithRest(
headers: Schema.optional(Schema.Unknown),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
).pipe(
Schema.decode({
decode: SchemaGetter.transform((event) => {
if (event.type !== "error" || event.error != null) return event
const { code, message, param, ...rest } = event
if (code === undefined && message === undefined && param === undefined) return event
// Flat errors (for example, Meta's) can also arrive through generic Responses endpoints.
return { ...rest, error: { code, message, param } }
}),
encode: SchemaGetter.passthrough(),
}),
)
export type Event = Schema.Schema.Type<typeof Event>
export type NormalizedEvent = Event & { readonly item?: OutputItem | null }
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
@@ -1,7 +1,8 @@
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import type { ProviderPackage } from "../provider-package.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { OpenAIResponses } from "../protocols/openai-responses.js"
import { OpenResponses } from "../protocols/open-responses.js"
import { BedrockAuth, type Credentials } from "../protocols/utils/bedrock-auth.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options.js"
@@ -37,11 +38,10 @@ const responsesRoute = Route.make({
id: "bedrock-mantle-responses",
provider: id,
providerMetadataKey: "mantle",
protocol: OpenAIResponses.protocol,
endpoint: OpenAIResponses.route.endpoint,
auth: OpenAIResponses.route.auth,
transport: OpenAIResponses.httpTransport,
defaults: OpenAIResponses.route.defaults,
protocol: OpenResponses.protocol,
endpoint: Endpoint.path(OpenResponses.PATH),
transport: OpenResponses.httpTransport,
defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } },
})
const chatRoute = OpenAIChat.route.with({
+4
View File
@@ -41,6 +41,10 @@ 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"] } },
})
@@ -4,7 +4,7 @@ import { HttpClientRequest } from "effect/unstable/http"
import { LLM, Message } from "../../src/index.js"
import { AmazonBedrockMantle } from "../../src/providers.js"
import { model } from "../../src/providers/amazon-bedrock/mantle.js"
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { compileRequest, LLMClient } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { withProcessEnv } from "../lib/env.js"
@@ -25,7 +25,7 @@ describe("Amazon Bedrock Mantle provider", () => {
expect(provider.model).toBe(provider.responses)
expect(AmazonBedrockMantle.model).toBe(AmazonBedrockMantle.responsesModel)
expect(model).toBe(AmazonBedrockMantle.responsesModel)
expect(provider.model("openai.gpt-oss-120b").route.transport).toBe(OpenAIResponses.httpTransport)
expect(provider.model("openai.gpt-oss-120b").route.transport).toBe(OpenResponses.httpTransport)
const chat = yield* compileRequest(LLM.request({ model: provider.chat("openai.gpt-oss-120b"), prompt: "Hi" }))
const responses = yield* compileRequest(
LLM.request({ model: provider.model("openai.gpt-oss-120b"), prompt: "Hi" }),
@@ -38,7 +38,7 @@ describe("Amazon Bedrock Mantle provider", () => {
})
expect(responses).toMatchObject({
route: "bedrock-mantle-responses",
protocol: "openai-responses",
protocol: "open-responses",
body: { model: "openai.gpt-oss-120b", store: false },
})
expect(provider.model("openai.gpt-oss-120b").route.providerMetadataKey).toBe("mantle")
@@ -178,7 +178,7 @@ describe("Amazon Bedrock Mantle provider", () => {
const recorded = recordedTests({
prefix: "bedrock-mantle",
provider: "amazon-bedrock",
protocol: "openai-responses",
protocol: "open-responses",
requires: ["AWS_BEARER_TOKEN_BEDROCK"],
metadata: { model: "openai.gpt-oss-120b" },
})
@@ -0,0 +1,78 @@
import { expect } from "bun:test"
import { Effect, Schema } from "effect"
import { LLM, LLMClient } from "../../src/index.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { Meta } from "../../src/providers/index.js"
import { configure } from "../../src/providers/openai-compatible-responses.js"
import { it } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
it.effect("normalizes flat errors in shared SSE and WebSocket decoding", () =>
Effect.gen(function* () {
const frame = {
type: "error",
sequence_number: 4,
code: "server_shutting_down",
message: "Server is shutting down. Please retry your request.",
param: null,
}
for (const decode of [decodeEvent, OpenResponses.decodeChannelEvent]) {
const event = yield* decode(JSON.stringify(frame))
expect(event).toEqual({
type: "error",
sequence_number: 4,
error: { code: frame.code, message: frame.message, param: null },
})
for (const unchanged of [
event,
{ type: "error" },
{
type: "response.failed",
response: { id: "resp_failed", error: { code: "server_error", message: "Internal server error" } },
},
{ type: "response.output_text.delta", item_id: "msg_text", delta: "Hello" },
]) {
expect(yield* decode(JSON.stringify(unchanged))).toEqual(unchanged)
}
}
}),
)
it.effect("continues to normalize untyped xAI WebSocket errors", () =>
Effect.gen(function* () {
const frame = { error: { type: "api_error", message: "gRPC error: Response with id=resp_missing not found" } }
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify(frame))).toEqual({ ...frame, type: "error" })
}),
)
it.effect("retains classification and original error bodies through Meta and generic Responses routes", () =>
Effect.gen(function* () {
const raw = `{
"type": "error",
"sequence_number": 4,
"code": "server_shutting_down",
"message": "Server is shutting down. Please retry your request.",
"param": null,
"diagnostic": "retain-original-frame"
}`
for (const model of [
Meta.configure({ apiKey: "fixture" }).responses("muse-spark-1.3"),
configure({ apiKey: "fixture", provider: "gateway", baseURL: "https://responses.example.test/v1" }).model(
"example-model",
),
]) {
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" })).pipe(
Effect.provide(fixedResponse(sseEvents(raw.replaceAll("\n", "\ndata: ")))),
Effect.flip,
)
expect(error.reason._tag).toBe("ProviderInternal")
expect(error.message).toBe("server_shutting_down: Server is shutting down. Please retry your request.")
expect(error.reason.body).toBe(raw)
expect(error.reason.http?.status).toBe(200)
}
}),
)
@@ -90,7 +90,11 @@ const classifyingChannelDriver = (message: string): WebSocketChannelDriver => {
}
}
const continuationDriver = (request: Readonly<Record<string, unknown>>, base = baseChannelDriver) => {
const continuationDriver = (
request: Readonly<Record<string, unknown>>,
base = baseChannelDriver,
continuation?: OpenResponsesContinuation.Shape,
) => {
const message = ProviderShared.encodeJson(request)
return OpenResponsesContinuation.driver({
id: "openai-responses",
@@ -98,6 +102,7 @@ const continuationDriver = (request: Readonly<Record<string, unknown>>, base = b
request,
message,
base: base(message),
continuation,
})
}
@@ -921,6 +926,58 @@ 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)
}),
)
+110 -2
View File
@@ -1,11 +1,18 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Effect, Layer, Stream } 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 } from "../../src/route.js"
import {
LLMClient,
RequestExecutor,
WebSocketTransport,
type ChannelCheckpoint,
type WebSocketChannelDriver,
} from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
@@ -13,6 +20,35 @@ 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* () {
@@ -162,6 +198,78 @@ 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" } }
@@ -0,0 +1,74 @@
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,7 +122,15 @@ test("renders compaction progress, summary, and outcome in order", async ({ page
)
await expect(compaction.getByRole("heading", { name: "Checkpoint" })).toBeVisible()
await expect(compaction).toContainText("Streamed implementation details.")
await expect(compaction.getByRole("status").getByLabel("Compacting", { exact: true })).toBeVisible()
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.getByText("Session compacted", { exact: true })).toHaveCount(0)
await timeline.send(
+16 -14
View File
@@ -88,8 +88,12 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
if (value.mode === "normal" && !command) {
session.handoff?.set(handoffMessage(value))
const optimisticBusy = !input.adapter.working()
if (optimisticBusy) session.data.session.setStatus(session.id, "running")
const sending = sendPrompt(session, value, input.adapter.controls().model.selection.trackSessionCommit).then(
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(
() => ({ ok: true as const }),
(error) => ({ ok: false as const, error }),
)
@@ -122,15 +126,9 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
if (command) {
clearSubmission(input, submission)
// Commands always steer: the server applies a command's configured
// agent and model immediately at admission, so queueing one would
// reconfigure the turn it is supposed to wait behind.
void sendCommand(
session,
{ ...value, delivery: "steer" },
command,
input.adapter.controls().model.selection.trackSessionCommit,
).catch((error) => failSubmission(input, session, "command", error, restore, value.id))
void sendCommand(session, value, command, input.adapter.controls().model.selection.trackSessionCommit).catch(
(error) => failSubmission(input, session, "command", error, restore, value.id),
)
return
}
} finally {
@@ -322,7 +320,8 @@ async function sendCommand(
track?: ModelSelection["trackSessionCommit"],
) {
const request = await buildSubmissionRequest(session, value)
await applySelection(session, value.selection, track)
// Like queued prompts, queued commands must not apply the composer's selection to active work.
if (value.delivery === "steer") await applySelection(session, value.selection, track)
await session.api.command({
sessionID: session.id,
command: command.command,
@@ -359,7 +358,8 @@ async function applySelection(
async function sendPrompt(
session: ComposerSession,
value: ComposerSubmission,
track?: ModelSelection["trackSessionCommit"],
track: ModelSelection["trackSessionCommit"] | undefined,
onAdmit: () => void,
) {
const request = await buildSubmissionRequest(session, value)
// Switching agent or model reconfigures the session immediately, and with it
@@ -389,7 +389,9 @@ async function sendPrompt(
},
},
}
await session.data.session.prompt(admission).catch(() => session.data.session.prompt(admission))
const sending = session.data.session.prompt(admission).catch(() => session.data.session.prompt(admission))
onAdmit()
await sending
}
async function buildSubmissionRequest(session: ComposerSession, value: ComposerSubmission) {
@@ -515,6 +515,19 @@ 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` }}
@@ -47,7 +47,7 @@ const handler = Effect.fn("cli.session.list")(function* (
null,
2,
)
: formatTable(page.data)) + EOL
: formatList(page.data)) + EOL
const write = Effect.tryPromise(
() =>
new Promise<void>((resolve, reject) => {
@@ -96,18 +96,14 @@ export default Runtime.handler(Commands.commands.session.commands.list, (input)
),
)
function formatTable(sessions: ReadonlyArray<SessionInfo>) {
const rows = sessions.map((session) => ({
id: session.id,
title: (session.title ?? "Untitled session").replace(/[\r\n\t]/g, " "),
updated: new Date(session.time.updated).toLocaleString(),
}))
const idWidth = Math.max(20, ...rows.map((row) => row.id.length))
const titleWidth = Math.max(25, ...rows.map((row) => row.title.length))
const header = `${"Session ID".padEnd(idWidth)} ${"Title".padEnd(titleWidth)} Updated`
return [
header,
"─".repeat(header.length),
...rows.map((row) => `${row.id.padEnd(idWidth)} ${row.title.padEnd(titleWidth)} ${row.updated}`),
].join(EOL)
function formatList(sessions: ReadonlyArray<SessionInfo>) {
return sessions
.map((session) =>
[
session.id,
(session.title ?? "Untitled session").replace(/[\r\n\t]/g, " "),
new Date(session.time.updated).toLocaleString(),
].join("\t"),
)
.join(EOL)
}
+19 -1
View File
@@ -11,6 +11,7 @@ import type { RelativePath } from "@opencode/schema/schema"
import type { Brand } from "effect"
import type { Model } from "@opencode/schema/model"
import type { DateTime } from "effect"
import type { Permission } from "@opencode/schema/permission"
import type { SessionMessage } from "@opencode/schema/session-message"
import type { SessionInbox } from "@opencode/schema/session-inbox"
import type { PromptInput } from "@opencode/schema/prompt-input"
@@ -26,7 +27,6 @@ import type { Integration } from "@opencode/schema/integration"
import type { Form } from "@opencode/schema/form"
import type { Mcp } from "@opencode/schema/mcp"
import type { Credential } from "@opencode/schema/credential"
import type { Permission } from "@opencode/schema/permission"
import type { PermissionSaved } from "@opencode/schema/permission-saved"
import type { FileSystem } from "@opencode/schema/filesystem"
import type { Command } from "@opencode/schema/command"
@@ -209,6 +209,7 @@ export type SessionCreateInput = {
readonly model?: Model.Ref | undefined
readonly location?: Location.Ref | undefined
readonly metadata?: Session.Metadata | undefined
readonly permissions?: Permission.Ruleset | undefined
}
export type SessionCreateOutput = Session.Info
export type SessionCreateOperation<E = never> = (input?: SessionCreateInput) => Effect.Effect<SessionCreateOutput, E>
@@ -437,6 +438,7 @@ export type SessionLogOutput =
readonly agent?: Agent.ID | undefined
readonly model?: Model.Ref | undefined
readonly metadata?: Session.Metadata | undefined
readonly permissions?: Permission.Ruleset | undefined
readonly version: string
}
}
@@ -489,6 +491,15 @@ export type SessionLogOutput =
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly title: string }
}
| {
readonly id: Event.ID
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.permissions.updated"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly permissions: Permission.Ruleset }
}
| {
readonly id: Event.ID
readonly created: number
@@ -1585,6 +1596,12 @@ export type PermissionReplyOperation<E = never> = (
input: PermissionReplyInput,
) => Effect.Effect<PermissionReplyOutput, E>
export type PermissionRulesInput = { readonly sessionID: Session.ID; readonly permissions: Permission.Ruleset }
export type PermissionRulesOutput = void
export type PermissionRulesOperation<E = never> = (
input: PermissionRulesInput,
) => Effect.Effect<PermissionRulesOutput, E>
export interface PermissionApi<E = never> {
readonly request: { readonly list: PermissionRequestListOperation<E> }
readonly saved: { readonly list: PermissionSavedListOperation<E>; readonly remove: PermissionSavedRemoveOperation<E> }
@@ -1592,6 +1609,7 @@ export interface PermissionApi<E = never> {
readonly list: PermissionListOperation<E>
readonly get: PermissionGetOperation<E>
readonly reply: PermissionReplyOperation<E>
readonly rules: PermissionRulesOperation<E>
}
export type FileListInput = {
@@ -181,6 +181,8 @@ import type {
PermissionGetOutput,
PermissionReplyInput,
PermissionReplyOutput,
PermissionRulesInput,
PermissionRulesOutput,
FileListInput,
FileListOutput,
FileFindInput,
@@ -395,6 +397,7 @@ const EndpointSessionCreate = (raw: RawClient["server.session"]) => (input?: Ses
model: input?.["model"],
location: input?.["location"],
metadata: input?.["metadata"],
permissions: input?.["permissions"],
},
}).pipe(
Effect.mapError(mapClientError),
@@ -1145,6 +1148,14 @@ const EndpointPermissionReply = (raw: RawClient["server.permission"]) => (input:
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointPermissionRules = (raw: RawClient["server.permission"]) => (input: PermissionRulesInput) =>
preserveEffect<PermissionRulesOutput>()(
raw["session.permission.rules"]({
params: { sessionID: input["sessionID"] },
payload: { permissions: input["permissions"] },
}).pipe(Effect.mapError(mapClientError)),
)
const adaptGroupPermission = (raw: RawClient["server.permission"]) => ({
request: { list: EndpointPermissionRequestList(raw) },
saved: { list: EndpointPermissionSavedList(raw), remove: EndpointPermissionSavedRemove(raw) },
@@ -1152,6 +1163,7 @@ const adaptGroupPermission = (raw: RawClient["server.permission"]) => ({
list: EndpointPermissionList(raw),
get: EndpointPermissionGet(raw),
reply: EndpointPermissionReply(raw),
rules: EndpointPermissionRules(raw),
})
const EndpointFileList = (raw: RawClient["server.fs"]) => (input?: FileListInput) =>
@@ -175,6 +175,8 @@ import type {
PermissionGetOutput,
PermissionReplyInput,
PermissionReplyOutput,
PermissionRulesInput,
PermissionRulesOutput,
FileReadInput,
FileReadOutput,
FileListInput,
@@ -565,6 +567,7 @@ export function make(options: ClientOptions) {
model: input?.["model"],
location: input?.["location"],
metadata: input?.["metadata"],
permissions: input?.["permissions"],
},
successStatus: 200,
declaredStatuses: [400, 401],
@@ -1566,6 +1569,18 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
rules: (input: PermissionRulesInput, requestOptions?: RequestOptions) =>
request<PermissionRulesOutput>(
{
method: "PUT",
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission/rules`,
body: { permissions: input["permissions"] },
successStatus: 204,
declaredStatuses: [400, 401, 404],
empty: true,
},
requestOptions,
),
},
file: {
read: (input: FileReadInput, requestOptions?: RequestOptions) =>
+127 -42
View File
@@ -551,28 +551,6 @@ export type InstructionEntryInfo = { key: InstructionEntryKey; value: JsonValue
export type InstructionEntrySnapshot = Array<{ key: InstructionEntryKey; value: JsonValue; removed: boolean }>
export type SessionCreated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.created"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: {
sessionID: string
projectID: string
location: LocationRef
subpath?: string
parentID?: string
slug: string
title?: string
agent?: string
model?: ModelRef
metadata?: SessionMetadata
version: string
}
}
export type SessionAgentSelected = {
id: string
created: number
@@ -1651,24 +1629,6 @@ export type SessionInboxMove = {
delivery: SessionInboxDelivery
}
export type SessionInfo = {
id: string
parentID?: string
fork?: { sessionID: string; boundary: SessionForkBoundary }
projectID: string
agent?: string
model?: ModelRef
cost: MoneyUSD
tokens: TokenUsageInfo
outcome?: "succeeded" | "failed" | "interrupted"
time: { created: number; updated: number; idle?: number; viewed?: number; archived?: number }
title?: string
location: LocationRef
subpath?: string
metadata?: SessionMetadata
revert?: SessionRevert
}
export type SessionRevertStaged = {
id: string
created: number
@@ -1912,6 +1872,58 @@ export type AgentInfo = {
permissions: PermissionRuleset
}
export type SessionPermissionsUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.permissions.updated"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; permissions: PermissionRuleset }
}
export type SessionInfo = {
id: string
parentID?: string
fork?: { sessionID: string; boundary: SessionForkBoundary }
projectID: string
agent?: string
model?: ModelRef
cost: MoneyUSD
tokens: TokenUsageInfo
outcome?: "succeeded" | "failed" | "interrupted"
time: { created: number; updated: number; idle?: number; viewed?: number; archived?: number }
title?: string
location: LocationRef
subpath?: string
metadata?: SessionMetadata
permissions?: PermissionRuleset
revert?: SessionRevert
}
export type SessionCreated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.created"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: {
sessionID: string
projectID: string
location: LocationRef
subpath?: string
parentID?: string
slug: string
title?: string
agent?: string
model?: ModelRef
metadata?: SessionMetadata
permissions?: PermissionRuleset
version: string
}
}
export type ConfigEntry =
| {
type: "document"
@@ -2084,8 +2096,6 @@ export type ConfigEntry =
| { type: "agents"; path: string }
| { type: "claude"; path: string }
export type SessionsResponse = { data: Array<SessionInfo>; cursor: { previous?: string | null; next?: string | null } }
export type SessionInboxUser = {
id: string
sessionID: string
@@ -2140,6 +2150,8 @@ export type FormFields = [FormField, ...Array<FormField>]
export type FormFields2 = [FormField1, ...Array<FormField1>]
export type SessionsResponse = { data: Array<SessionInfo>; cursor: { previous?: string | null; next?: string | null } }
export type SessionInboxInfo = SessionInboxUser | SessionInboxSynthetic | SessionInboxCompaction | SessionInboxMove
export type SessionInboxEnqueued = {
@@ -2233,6 +2245,7 @@ export type SessionEventDurable =
| SessionModelSelected
| SessionMoved
| SessionRenamed
| SessionPermissionsUpdated
| SessionViewed
| SessionDeleted
| SessionForked
@@ -2292,6 +2305,7 @@ export type V2Event =
| SessionModelSelected
| SessionMoved
| SessionRenamed
| SessionPermissionsUpdated
| SessionViewed
| SessionUsageUpdated
| SessionDeleted
@@ -2804,6 +2818,11 @@ export type SessionCreateInput = {
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}> | null
}["id"]
readonly title?: {
readonly id?: string | null
@@ -2812,6 +2831,11 @@ export type SessionCreateInput = {
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}> | null
}["title"]
readonly agent?: {
readonly id?: string | null
@@ -2820,6 +2844,11 @@ export type SessionCreateInput = {
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}> | null
}["agent"]
readonly model?: {
readonly id?: string | null
@@ -2828,6 +2857,11 @@ export type SessionCreateInput = {
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}> | null
}["model"]
readonly location?: {
readonly id?: string | null
@@ -2836,6 +2870,11 @@ export type SessionCreateInput = {
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}> | null
}["location"]
readonly metadata?: {
readonly id?: string | null
@@ -2844,7 +2883,25 @@ export type SessionCreateInput = {
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}> | null
}["metadata"]
readonly permissions?: {
readonly id?: string | null
readonly title?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}> | null
}["permissions"]
}
export type SessionCreateOutput = { data: SessionInfo }["data"]
@@ -2882,6 +2939,11 @@ export type SessionImportInput = {
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly subpath?: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}>
readonly revert?: {
readonly messageID: string
readonly partID?: string
@@ -3187,6 +3249,11 @@ export type SessionImportInput = {
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly subpath?: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}>
readonly revert?: {
readonly messageID: string
readonly partID?: string
@@ -3492,6 +3559,11 @@ export type SessionImportInput = {
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly subpath?: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}>
readonly revert?: {
readonly messageID: string
readonly partID?: string
@@ -5753,6 +5825,19 @@ export type PermissionReplyInput = {
export type PermissionReplyOutput = void
export type PermissionRulesInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly permissions: {
readonly permissions: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}>
}["permissions"]
}
export type PermissionRulesOutput = void
export type FileReadInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
+4
View File
@@ -695,6 +695,10 @@ export function createData(config: CreateDataInput) {
})
return
}
case "session.permissions.updated":
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "permissions", event.data.permissions)
return
case "session.moved": {
const current = store.session.info[event.data.sessionID]
if (current) {
+27 -12
View File
@@ -31,8 +31,8 @@ ultimate source of truth.
- [x] `null`, `undefined`, booleans, finite and non-finite numbers, and strings.
- [x] Array literals, including holes and spread from arrays, strings, Maps, Sets, URLSearchParams, custom synchronous
iterators, and synchronous generators.
- [x] Object literals with shorthand, computed string/number keys, and spread from plain data objects; `null` and
`undefined` are no-ops, while arrays are rejected.
- [x] Object literals with shorthand, computed string/number keys, and spread following ToObject: data objects and
arrays copy own enumerable keys, strings copy index keys, and other values contribute nothing.
- [x] Template literals with interpolation.
- [x] Regular-expression literals.
- [x] `NaN` and `Infinity` globals.
@@ -44,18 +44,21 @@ ultimate source of truth.
## Bindings and destructuring
- [x] `const`, `let`, and accepted `var` declarations.
- [x] `const`, `let`, and `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.
- [ ] JavaScript-correct function scoping, hoisting, and redeclaration for accepted `var` declarations.
- [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.
- [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.
- [ ] Hoist function declarations accepted directly in switch cases.
- [x] Function declarations are hoisted across all cases of a `switch`, like any other statement list.
- [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
@@ -70,7 +73,7 @@ ultimate source of truth.
- [x] `for`, `while`, and `do...while`.
- [x] `for...of` over arrays, strings, Maps, Sets, URLSearchParams, custom synchronous iterators, and confined
synchronous generators. Abrupt completion invokes the iterator's optional `return()`.
- [x] `for...in` over own keys of plain objects, arrays, and tool references.
- [x] `for...in` over own keys of plain objects, arrays, strings, and tool references; other values iterate nothing.
- [x] Unlabeled `break` and `continue`.
- [x] `try`, `catch`, optional catch bindings, and `finally`.
- [x] `throw` with arbitrary values.
@@ -210,7 +213,9 @@ ultimate source of truth.
primitive wrapper objects (`Object(1)`) are rejected explicitly.
- [x] Computed property names and object spread.
- [x] `Object.keys`, `Object.values`, `Object.entries`, `Object.hasOwn`, `Object.assign`, and `Object.fromEntries`, with
synchronous iterator support for `fromEntries`.
synchronous iterator support for `fromEntries`. Sources follow ToObject: strings enumerate by index, other
primitives and wrappers contribute nothing, and `null`/`undefined` throw. `Object.assign` accepts array
targets for index keys only; a primitive target is a `TypeError` rather than a boxed object.
- [x] `Object.keys` over arrays and tool references.
- [x] Object identity is preserved by in-CodeMode Object helpers.
- [x] `__proto__`, `constructor`, and `prototype` are ordinary own data keys. `x.constructor` without an own key resolves
@@ -248,12 +253,13 @@ ultimate source of truth.
## Strings
- [x] Case/normalization: `toLowerCase`, `toUpperCase`, `normalize`.
- [x] Trimming: `trim`, `trimStart`, and `trimEnd`.
- [x] Trimming: `trim`, `trimStart`, and `trimEnd`, plus the Annex B `trimLeft` and `trimRight` aliases.
- [x] Searching/tests: `includes`, `startsWith`, `endsWith`, `indexOf`, `lastIndexOf`, and `search`.
- [x] Slicing/access: `slice`, `substring`, `at`, `charAt`, `charCodeAt`, and `codePointAt`.
- [x] Slicing/access: `slice`, `substring`, Annex B `substr`, `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
@@ -314,6 +320,7 @@ 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.
@@ -357,6 +364,14 @@ 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
@@ -373,6 +388,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.
- [ ] 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.
- [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.
+2 -2
View File
@@ -105,7 +105,7 @@ export type Result = typeof Result.Type
/** Reusable confined runtime over explicit tools. */
export type Runtime<R = never> = {
readonly catalog: () => ReadonlyArray<ToolDescription>
readonly catalog: ReadonlyArray<ToolDescription>
readonly execute: (code: string) => Effect.Effect<Result, never, R>
}
@@ -134,7 +134,7 @@ export const make = <const Provided extends Record<string, unknown> = {}>(
const prepared = ToolRuntime.prepare((options.tools ?? {}) as Tools<Services<Provided>>)
const limits = resolveExecutionLimits(options.limits)
return {
catalog: () => prepared.catalog,
catalog: prepared.catalog,
execute: (code) => executeProgram(code, prepared, limits, options),
}
}
@@ -11,6 +11,7 @@ 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"
@@ -71,5 +72,8 @@ 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,9 +118,11 @@ 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.
@@ -241,6 +243,15 @@ 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
+24 -22
View File
@@ -32,6 +32,17 @@ 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.
@@ -126,11 +137,7 @@ export const resolvePromise = <R>(
node: AstNode,
): Effect.Effect<Values.Promise, never, R> => {
if (value instanceof Values.Promise) return Effect.succeed(value)
const box: { promise?: Values.Promise } = {}
return Effect.map(promises.create(resolvePromiseValue(runner, value, node, box)), (promise) => {
box.promise = promise
return promise
})
return promises.createWithSelf((self) => resolvePromiseValue(runner, value, node, self))
}
const promiseStatics = ["all", "allSettled", "race", "any", "resolve", "reject"] as const
@@ -254,11 +261,9 @@ const constructPromise = <R>(
}
return Effect.gen(function* () {
const deferred = Deferred.makeUnsafe<unknown, unknown>()
const box: { promise?: Values.Promise } = {}
const promise = yield* promises.create(
Effect.flatMap(Deferred.await(deferred), (value) => resolvePromiseValue(runner, value, node, box)),
const promise = yield* promises.createWithSelf((self) =>
Effect.flatMap(Deferred.await(deferred), (value) => resolvePromiseValue(runner, value, node, self)),
)
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]))
@@ -310,19 +315,16 @@ const chainReaction = <R>(
method: string,
node: AstNode,
): Effect.Effect<Values.Promise, never, R> => {
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
})
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 chainFinally = <R>(
@@ -108,3 +108,12 @@ export const typeofValue = (value: unknown): string => {
if (value instanceof ToolReference) return value.path.length > 0 ? "function" : "object"
return typeof value
}
const MAX_ARRAY_LENGTH = 4_294_967_295
export const parseArrayIndex = (key: string | number): number | undefined => {
const property = String(key)
if (!/^(0|[1-9]\d*)$/.test(property)) return undefined
const index = Number(property)
return index < MAX_ARRAY_LENGTH ? index : undefined
}
+118 -129
View File
@@ -75,6 +75,7 @@ import {
containsOpaqueReference,
describeValue,
isRuntimeReference,
parseArrayIndex,
rejectCircularInsertion,
typeofValue,
} from "./references.js"
@@ -85,16 +86,20 @@ import { numberMethods } from "../stdlib/number.js"
import { constructRegExp, regexpMethods, regexpProperties } from "../stdlib/regexp.js"
import { stringMethods } from "../stdlib/string.js"
import { uriArgument, urlMethods, urlProperties, urlSearchParamsMethods, urlWritableProperties } from "../stdlib/url.js"
import { enumerableSource } from "../stdlib/object.js"
import { coerceToNumber, coerceToString, compoundOperators, errorBrandName } from "../stdlib/value.js"
import { Values } from "../values.js"
const MAX_ARRAY_LENGTH = 4_294_967_295
const parseArrayIndex = (key: string | number): number | undefined => {
const property = String(key)
if (!/^(0|[1-9]\d*)$/.test(property)) return undefined
const index = Number(property)
return index < MAX_ARRAY_LENGTH ? index : undefined
// 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 => {
@@ -165,6 +170,51 @@ 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
@@ -271,6 +321,7 @@ 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") {
@@ -394,6 +445,20 @@ 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
@@ -431,7 +496,9 @@ class Frame<R> {
self.scopes.push()
return yield* Effect.gen(function* () {
const cases = node.cases
self.predeclareLexical(cases.flatMap((branch) => branch.consequent))
const statements = cases.flatMap((branch) => branch.consequent)
self.predeclareLexical(statements)
self.hoistFunctions(statements)
let defaultIndex: number | undefined
let selected: number | undefined
for (const [index, branch] of cases.entries()) {
@@ -473,21 +540,8 @@ class Frame<R> {
const self = this
return Effect.gen(function* () {
while (yield* self.evaluateExpression(node.test)) {
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
}
const exit = loopExit(yield* self.evaluateStatement(node.body), labels)
if (exit !== undefined) return exit
}
return { kind: "none" } satisfies StatementResult
@@ -501,21 +555,8 @@ class Frame<R> {
const self = this
return Effect.gen(function* () {
do {
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
}
const exit = loopExit(yield* self.evaluateStatement(node.body), labels)
if (exit !== undefined) return exit
} while (yield* self.evaluateExpression(node.test))
return { kind: "none" } satisfies StatementResult
@@ -561,27 +602,13 @@ class Frame<R> {
nextIteration()
while (testNode ? yield* self.evaluateExpression(testNode) : true) {
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
const exit = loopExit(yield* self.evaluateStatement(node.body), labels)
if (exit !== undefined) return exit
nextIteration()
if (updateNode) {
yield* self.evaluateExpression(updateNode)
}
if (result.kind === "continue") {
continue
}
}
return { kind: "none" } satisfies StatementResult
@@ -624,10 +651,12 @@ class Frame<R> {
const evaluateBody = (value: unknown) =>
Effect.gen(function* () {
if (declared) {
if (declared?.lexical) {
self.scopes.push()
if (declared.lexical) self.predeclarePattern(declared.pattern, declared.mutable, left)
yield* self.declarePattern(declared.pattern, value, declared.mutable, left, declared.lexical)
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)
} else if (assignment) {
yield* self.assignPattern(assignment, value, left)
}
@@ -635,7 +664,7 @@ class Frame<R> {
}).pipe(
Effect.ensuring(
Effect.sync(() => {
if (declared) self.scopes.pop()
if (declared?.lexical) self.scopes.pop()
}),
),
)
@@ -654,22 +683,10 @@ class Frame<R> {
}
return yield* Effect.failCause(bodyExit.cause)
}
const result = bodyExit.value
if (result.kind === "return") {
const exit = loopExit(bodyExit.value, labels)
if (exit !== undefined) {
yield* close()
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
return exit
}
}
}).pipe(
@@ -852,17 +869,11 @@ class Frame<R> {
throw new InterpreterRuntimeError(`${context} must be a function.`, node).as("TypeError")
}
private enumerableKeys(value: unknown): Array<string> | undefined {
if (value instanceof ToolReference) {
return [...this.runtime.toolKeys(value.path)]
}
if (Array.isArray(value)) {
return Object.keys(value)
}
if (value !== null && typeof value === "object" && !isRuntimeReference(value)) {
return Object.keys(value)
}
return undefined
// for...in over null/undefined iterates nothing, like JS.
private enumerableKeys(value: unknown, node: AstNode): Array<string> {
if (value instanceof ToolReference) return [...this.runtime.toolKeys(value.path)]
if (value === null || value === undefined) return []
return Object.keys(enumerableSource("for...in", value, node))
}
private evaluateForInStatement(
@@ -878,13 +889,7 @@ class Frame<R> {
if (declared?.lexical) self.predeclarePattern(declared.pattern, declared.mutable, left)
const right = yield* self.evaluateExpression(node.right)
const keys = self.enumerableKeys(right)
if (keys === undefined) {
throw new InterpreterRuntimeError(
"for...in requires a plain object, array, or tools reference. Use for...of for arrays/strings/Maps/Sets, or Object.keys(value) for a key list.",
node,
)
}
const keys = self.enumerableKeys(right, node.right)
if (left.type !== "Identifier" && left.type !== "VariableDeclaration") {
throw new InterpreterRuntimeError("Unsupported for...in binding.", left)
@@ -893,10 +898,12 @@ class Frame<R> {
for (const key of keys) {
const result = yield* Effect.gen(function* () {
if (declared) {
if (declared?.lexical) {
self.scopes.push()
if (declared.lexical) self.predeclarePattern(declared.pattern, declared.mutable, left)
yield* self.declarePattern(declared.pattern, key, declared.mutable, left, declared.lexical)
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)
} else if (assignmentName) {
self.scopes.set(assignmentName, key, left)
}
@@ -904,24 +911,13 @@ class Frame<R> {
}).pipe(
Effect.ensuring(
Effect.sync(() => {
if (declared) self.scopes.pop()
if (declared?.lexical) self.scopes.pop()
}),
),
)
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
}
const exit = loopExit(result, labels)
if (exit !== undefined) return exit
}
return { kind: "none" } satisfies StatementResult
@@ -1022,8 +1018,13 @@ 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, kind !== "var")
yield* self.declarePattern(declaration.id, value, kind !== "const", declaration, true)
}
})
}
@@ -1644,6 +1645,8 @@ 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
}
@@ -1652,16 +1655,8 @@ class Frame<R> {
})
if (fn.generator) return Effect.succeed(this.createGenerator(invocation, run, fn.async))
if (!fn.async) return run
// 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
},
return this.runtime.promises.createWithSelf((self) =>
Effect.flatMap(run, (value) => resolvePromiseValue(invocation.runtime.runner, value, fn.body, self)),
)
}
@@ -1916,16 +1911,10 @@ class Frame<R> {
for (const property of node.properties) {
if (property.type === "SpreadElement") {
const spread = yield* self.evaluateExpression(property.argument)
if (spread === null || spread === undefined || Values.isValue(spread)) continue
if (typeof spread !== "object" || Array.isArray(spread) || isRuntimeReference(spread)) {
throw new InterpreterRuntimeError(
`Object spread requires a data object, received ${describeValue(spread)}.`,
property,
"InvalidDataValue",
)
}
for (const [key, value] of Object.entries(spread)) objectValue[key] = value
copyIteratorSymbols(spread, objectValue)
if (spread === null || spread === undefined) continue
const from = enumerableSource("Object spread", spread, property)
for (const [key, value] of Object.entries(from)) objectValue[key] = value
if (typeof from === "object") copyIteratorSymbols(from, objectValue)
continue
}
+6
View File
@@ -29,6 +29,8 @@ export const dateMethods = new Set([
"toISOString",
"toJSON",
"toString",
"toDateString",
"toTimeString",
"toUTCString",
"toGMTString",
"getFullYear",
@@ -103,6 +105,10 @@ 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()
+55 -49
View File
@@ -5,6 +5,8 @@ import { type AstNode, AsyncIteratorSymbol, InterpreterRuntimeError, IteratorSym
import {
containsOpaqueReference,
describeValue,
isRuntimeReference,
parseArrayIndex,
rejectCircularInsertion,
typeofValue,
} from "../interpreter/references.js"
@@ -14,28 +16,53 @@ import { Values } from "../values.js"
import { groupBy } from "./collections.js"
import { coerceToString } from "./value.js"
const requireObject = (name: string, input: unknown, node: AstNode): Record<string, unknown> => {
if (Array.isArray(input)) return input as unknown as Record<string, unknown>
if (Values.isValue(input)) return {}
const prototype = input === null || typeof input !== "object" ? undefined : Object.getPrototypeOf(input)
if (prototype !== null && prototype !== Object.prototype) {
// ToObject for enumeration. Strings return themselves: the host's Object.keys/entries/hasOwn index a
// primitive string directly. Numbers, booleans, wrappers, and functions have no own enumerable keys.
export const enumerableSource = (label: string, value: unknown, node: AstNode): Record<string, unknown> => {
if (value === null || value === undefined) {
throw new InterpreterRuntimeError(`${label} cannot convert ${describeValue(value)} to an object.`, node).as(
"TypeError",
)
}
if (value instanceof Values.Promise) {
throw new InterpreterRuntimeError(
`Object.${name} expects a data object or array, received ${describeValue(input)}.`,
`${label} received an un-awaited Promise; await it before inspecting the result.`,
node,
"InvalidDataValue",
)
}
return input as Record<string, unknown>
if (value instanceof ToolReference) {
throw new InterpreterRuntimeError(
`${label} cannot read tool references: they are not plain data. Use Object.keys(tools) for names, or search({ query }) for signatures.`,
node,
"InvalidDataValue",
)
}
if (typeof value === "string") return value as unknown as Record<string, unknown>
if (typeof value !== "object" || Values.isValue(value) || isRuntimeReference(value)) return {}
return value as Record<string, unknown>
}
export const objectAssign = (args: Array<unknown>, node: AstNode): unknown => {
const target = args[0]
if (target === null || typeof target !== "object" || Array.isArray(target) || Values.isValue(target)) {
throw new InterpreterRuntimeError("Object.assign expects a data object target.", node)
// JS would box a primitive target; wrappers and primitives cannot hold fields here.
if (target === null || typeof target !== "object" || Values.isValue(target) || isRuntimeReference(target)) {
throw new InterpreterRuntimeError(
`Object.assign expects a data object or array target, received ${describeValue(target)}.`,
node,
).as("TypeError")
}
const out = target as Record<string, unknown>
const seen = new Set<object>()
const guardedSet = (key: PropertyKey, item: unknown): void => {
// Arrays hold only indexed elements, as with direct assignment; Reflect.set would otherwise
// reach Array's length and Object.prototype's __proto__ setter.
if (Array.isArray(out) && (typeof key === "symbol" || parseArrayIndex(key) === undefined)) {
throw new InterpreterRuntimeError(
`Object.assign cannot assign '${String(key)}' to an array: only array indexes may be assigned.`,
node,
).as("TypeError")
}
rejectCircularInsertion(out, item, "Object.assign result", node, seen)
if (!Reflect.set(out, key, item))
throw new InterpreterRuntimeError(`Object.assign could not assign property '${String(key)}'.`, node).as(
@@ -43,18 +70,15 @@ export const objectAssign = (args: Array<unknown>, node: AstNode): unknown => {
)
}
for (const source of args.slice(1)) {
if (source === null || source === undefined || Values.isValue(source)) continue
if (typeof source !== "object" || Array.isArray(source)) {
throw new InterpreterRuntimeError("Object.assign expects data objects.", node)
if (source === null || source === undefined) continue
const from = enumerableSource("Object.assign(...)", source, node)
if (typeof from !== "object") {
for (const [key, item] of Object.entries(from)) guardedSet(key, item)
continue
}
for (const key of Reflect.ownKeys(source)) {
if (typeof key === "string") {
if (Object.prototype.propertyIsEnumerable.call(source, key)) guardedSet(key, Reflect.get(source, key))
continue
}
if (key !== AsyncIteratorSymbol && key !== IteratorSymbol) continue
if (!Object.prototype.propertyIsEnumerable.call(source, key)) continue
guardedSet(key, Reflect.get(source, key))
for (const key of Reflect.ownKeys(from)) {
if (typeof key === "symbol" && key !== AsyncIteratorSymbol && key !== IteratorSymbol) continue
if (Object.prototype.propertyIsEnumerable.call(from, key)) guardedSet(key, Reflect.get(from, key))
}
}
return out
@@ -110,22 +134,6 @@ const constructObject = (args: Array<unknown>, node: AstNode): unknown => {
)
}
// Tool references are not data; only Object.keys(tools) reads them, for tool names.
const rejectTools = (name: string, args: Array<unknown>, node: AstNode): void => {
if (!(args[0] instanceof ToolReference)) return
throw new InterpreterRuntimeError(
`Object.${name}(...) cannot read tool references: they are not plain data. Use Object.keys(tools) for names, or search({ query }) for signatures.`,
node,
"InvalidDataValue",
)
}
const objectStatic = (name: string, impl: (args: Array<unknown>, node: AstNode) => unknown) =>
sync(`Object.${name}`, (args, node) => {
rejectTools(name, args, node)
return impl(args, node)
})
// Object constructs identically with or without new, like JS. Only `keys` copies its result into the
// program; `values`, `entries`, `assign`, and `fromEntries` hand back the program's own values.
export const objectGlobal = <R>(runner: Runner<R>, toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>) =>
@@ -139,34 +147,32 @@ export const objectGlobal = <R>(runner: Runner<R>, toolKeys: (path: ReadonlyArra
toProgram(
args[0] instanceof ToolReference
? [...toolKeys(args[0].path)]
: Object.keys(requireObject("keys", args[0], node)),
: Object.keys(enumerableSource("Object.keys(...)", args[0], node)),
"Object.keys result",
),
),
values: objectStatic("values", (args, node) => Object.values(requireObject("values", args[0], node))),
entries: objectStatic("entries", (args, node) =>
Object.entries(requireObject("entries", args[0], node)).map(([key, item]) => [key, item]),
values: sync("Object.values", (args, node) =>
Object.values(enumerableSource("Object.values(...)", args[0], node)),
),
hasOwn: objectStatic("hasOwn", (args, node) =>
entries: sync("Object.entries", (args, node) =>
Object.entries(enumerableSource("Object.entries(...)", args[0], node)).map(([key, item]) => [key, item]),
),
hasOwn: sync("Object.hasOwn", (args, node) =>
Object.hasOwn(
requireObject("hasOwn", args[0], node),
enumerableSource("Object.hasOwn(...)", args[0], node),
args[1] === AsyncIteratorSymbol || args[1] === IteratorSymbol ? args[1] : String(args[1]),
),
),
is: objectStatic("is", (args, node) => {
is: sync("Object.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: objectStatic("assign", objectAssign),
assign: sync("Object.assign", objectAssign),
fromEntries: new HostFunction<R>({
name: "Object.fromEntries",
call: (args, node) =>
Effect.suspend(() => {
rejectTools("fromEntries", args, node)
return objectFromEntries(runner, args[0], node)
}),
call: (args, node) => Effect.suspend(() => objectFromEntries(runner, args[0], node)),
}),
groupBy: groupBy(runner, "Object"),
},
+5
View File
@@ -8,9 +8,12 @@ export const stringMethods = new Set([
"trim",
"trimStart",
"trimEnd",
"trimLeft",
"trimRight",
"split",
"slice",
"substring",
"substr",
"includes",
"startsWith",
"endsWith",
@@ -32,6 +35,8 @@ export const stringMethods = new Set([
"search",
"localeCompare",
"normalize",
"isWellFormed",
"toWellFormed",
])
const codeUnits = (name: string, op: (...codes: Array<number>) => string) =>
+23
View File
@@ -0,0 +1,23 @@
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 (a string)`, 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()),
})
+11
View File
@@ -0,0 +1,11 @@
# The 3-Clause BSD License
Copyright © web-platform-tests contributors
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+7 -7
View File
@@ -528,7 +528,7 @@ describe("CodeMode schema flexibility", () => {
})
const runtime = CodeMode.make({ tools: { adapter: { call } } })
expect(runtime.catalog()).toStrictEqual([
expect(runtime.catalog).toStrictEqual([
{
path: "adapter.call",
description: "Call an adapter-described tool",
@@ -611,7 +611,7 @@ describe("CodeMode schema flexibility", () => {
})
const runtime = CodeMode.make({ tools: { users: { lookup } } })
expect(runtime.catalog()).toStrictEqual([
expect(runtime.catalog).toStrictEqual([
{
path: "users.lookup",
description: "Look up a user",
@@ -631,7 +631,7 @@ describe("CodeMode schema flexibility", () => {
execute: () => Effect.succeed("pong"),
})
const runtime = CodeMode.make({ tools: { net: { ping } } })
expect(runtime.catalog()[0]?.signature).toBe("tools.net.ping(input: {\n host: string,\n}): Promise<void>")
expect(runtime.catalog[0]?.signature).toBe("tools.net.ping(input: {\n host: string,\n}): Promise<void>")
const result = await Effect.runPromise(runtime.execute(`return await tools.net.ping({ host: "example.test" })`))
expect(result.ok).toBe(true)
@@ -684,7 +684,7 @@ describe("CodeMode public contract", () => {
test("describes the catalog and keeps the search built-in registered", async () => {
const runtime = CodeMode.make({ tools })
expect(runtime.catalog()).toStrictEqual([
expect(runtime.catalog).toStrictEqual([
{
path: "orders.lookup",
description: "Look up an order by ID",
@@ -726,8 +726,8 @@ describe("CodeMode public contract", () => {
const first = CodeMode.make({ tools: { zeta: { zeta, alpha }, alpha: { zeta, alpha } } })
const second = CodeMode.make({ tools: { alpha: { alpha, zeta }, zeta: { alpha, zeta } } })
expect(first.catalog()).toStrictEqual(second.catalog())
expect(first.catalog().map((tool) => tool.path)).toEqual(["alpha.alpha", "alpha.zeta", "zeta.alpha", "zeta.zeta"])
expect(first.catalog).toStrictEqual(second.catalog)
expect(first.catalog.map((tool) => tool.path)).toEqual(["alpha.alpha", "alpha.zeta", "zeta.alpha", "zeta.zeta"])
})
test("renders bracket notation for tool names that are not JavaScript identifiers", async () => {
@@ -739,7 +739,7 @@ describe("CodeMode public contract", () => {
})
const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } })
expect(runtime.catalog()).toStrictEqual([
expect(runtime.catalog).toStrictEqual([
{
path: "context7.resolve-library-id",
description: "Resolve a library ID",
+25 -17
View File
@@ -85,18 +85,16 @@ describe("Object.keys over arrays", () => {
expect(await value(`return Object.keys({ a: 1, b: 2 })`)).toEqual(["a", "b"])
})
test("non-object inputs name what was received", async () => {
expect((await error(`return Object.keys("nope")`)).message).toContain(
"Object.keys expects a data object or array, received a string.",
)
expect((await error(`return Object.entries(42)`)).message).toContain("received a number.")
expect((await error(`return Object.values(null)`)).message).toContain("received null.")
test("non-object inputs follow ToObject, and nullish inputs name what was received", async () => {
expect(
await value(`return [Object.keys("ab"), Object.entries(42), Object.keys(() => 1), Object.keys(true)]`),
).toEqual([["0", "1"], [], [], []])
expect(await value(`try { Object.values(null) } catch (e) { return [e.name, e.message] }`)).toEqual([
"TypeError",
"Object.values(...) cannot convert null to an object.",
])
expect((await error(`return Object.keys(tools.github.list_issues({ value: "x" }))`)).message).toContain(
"received an un-awaited Promise.",
)
expect((await error(`return Object.entries(() => 1)`)).message).toContain("received a function.")
expect((await error(`return { ...[1] }`)).message).toContain(
"Object spread requires a data object, received an array.",
"received an un-awaited Promise",
)
expect((await error(`const { a } = new Map(); return a`)).message).toContain("received a Map.")
expect((await error(`return Array.from(7)`)).message).toContain("received a number.")
@@ -161,11 +159,21 @@ describe("for...in", () => {
).toEqual(["github.list_issues", "github.get_issue", "memory.search", "playwright.navigate"])
})
test("unsupported values fail with a hint at for...of and Object.keys", async () => {
for (const expression of [`"text"`, "new Map([[1, 2]])", "new Set([1])", "42", "null"]) {
const failure = await error(`for (const key in ${expression}) {}; return "no"`)
expect(failure.message).toContain("for...in requires a plain object, array, or tools reference")
expect(failure.message).toContain("Use for...of for arrays/strings/Maps/Sets, or Object.keys(value)")
}
test("non-object values enumerate like JS: strings by index, everything else nothing", async () => {
expect(
await value(`
const out = []
for (const key in "ab") out.push(key)
for (const key in 42) out.push(key)
for (const key in null) out.push(key)
for (const key in undefined) out.push(key)
for (const key in new Map([[1, 2]])) out.push(key)
for (const key in Math) out.push(key)
return out
`),
).toEqual(["0", "1"])
expect((await error(`for (const key in tools.github.list_issues({ value: "x" })) {}`)).message).toContain(
"un-awaited Promise",
)
})
})
+82
View File
@@ -0,0 +1,82 @@
[
["", []],
["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]
]
@@ -0,0 +1,217 @@
/**
* Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75:
* - test/built-ins/Object/keys/15.2.3.14-1-1.js
* - test/built-ins/Object/keys/15.2.3.14-1-2.js
* - test/built-ins/Object/keys/15.2.3.14-1-3.js
* - test/built-ins/Object/keys/15.2.3.14-1-4.js
* - test/built-ins/Object/keys/15.2.3.14-1-5.js
* - test/built-ins/Object/entries/primitive-strings.js
* - test/built-ins/Object/entries/primitive-numbers.js
* - test/built-ins/Object/entries/primitive-booleans.js
* - test/built-ins/Object/values/primitive-strings.js
* - test/built-ins/Object/values/primitive-numbers.js
* - test/built-ins/Object/values/primitive-booleans.js
* - test/built-ins/Object/hasOwn/toobject_null.js
* - test/built-ins/Object/hasOwn/toobject_undefined.js
* - test/built-ins/Object/hasOwn/hasown_nonexistent.js
* - test/built-ins/Object/assign/Source-String.js
* - test/built-ins/Object/assign/Source-Null-Undefined.js
* - test/built-ins/Object/assign/target-Array.js
* - test/built-ins/Object/assign/Target-Null.js
* - test/built-ins/Object/assign/Target-Undefined.js
* - test/built-ins/Object/assign/Target-Object.js
* - test/built-ins/Object/assign/Override.js
* - test/built-ins/Object/assign/ObjectOverride-sameproperty.js
*
* Copyright (c) 2012 Ecma International. All rights reserved.
* Copyright (C) 2015 Jordan Harband. All rights reserved.
* Copyright 2015 Microsoft Corporation. All rights reserved.
* Copyright 2021 Jamie Kyle. All rights reserved.
* Test262 portions are governed by the BSD license in LICENSE.test262.
*
* Boxed-primitive cases (`Object.assign("a")`, `Object.assign(1, …)`) are omitted: CodeMode has no
* wrapper objects, so a primitive target is a TypeError rather than a boxed result. `Override.js`
* checks `Object.keys(result).length` instead of `Object.getOwnPropertyNames`. `target-Array.js`
* omits its named-key (`-0`, `1.5`, `4294967295`), `length`, and Proxy assertions: arrays here hold
* only indexed elements, so those keys are a TypeError (pinned below) rather than array properties.
*/
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { CodeMode } from "../src/index.js"
const value = async (code: string) => {
const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} }))
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
return result.value
}
const throwsTypeError = (expression: string) =>
value(`try { ${expression}; return "no throw" } catch (error) { return error.name }`)
describe("Object.keys Test262 parity", () => {
test("test/built-ins/Object/keys/15.2.3.14-1-{1,2,3}.js: primitives are coerced", async () => {
expect(await value(`return [Object.keys(0), Object.keys(true), Object.keys("abc")]`)).toEqual([
[],
[],
["0", "1", "2"],
])
})
test("test/built-ins/Object/keys/15.2.3.14-1-{4,5}.js: null and undefined throw TypeError", async () => {
expect(await throwsTypeError(`Object.keys(null)`)).toBe("TypeError")
expect(await throwsTypeError(`Object.keys(undefined)`)).toBe("TypeError")
})
})
describe("Object.entries and Object.values Test262 parity", () => {
test("test/built-ins/Object/entries/primitive-strings.js", async () => {
expect(
await value(`
const result = Object.entries('abc')
return [Array.isArray(result), result.length, result[0][0], result[0][1], result[1][0], result[1][1], result[2][0], result[2][1]]
`),
).toEqual([true, 3, "0", "a", "1", "b", "2", "c"])
})
test("test/built-ins/Object/entries/primitive-numbers.js", async () => {
expect(
await value(`
return [0, -0, Infinity, -Infinity, NaN, Math.PI].map((number) => Object.entries(number).length)
`),
).toEqual([0, 0, 0, 0, 0, 0])
})
test("test/built-ins/Object/entries/primitive-booleans.js", async () => {
expect(
await value(`
const trueResult = Object.entries(true)
const falseResult = Object.entries(false)
return [Array.isArray(trueResult), trueResult.length, Array.isArray(falseResult), falseResult.length]
`),
).toEqual([true, 0, true, 0])
})
test("test/built-ins/Object/values/primitive-strings.js", async () => {
expect(
await value(`
const result = Object.values('abc')
return [Array.isArray(result), result.length, result[0], result[1], result[2]]
`),
).toEqual([true, 3, "a", "b", "c"])
})
test("test/built-ins/Object/values/primitive-numbers.js", async () => {
expect(
await value(`
return [0, -0, Infinity, -Infinity, NaN, Math.PI].map((number) => Object.values(number).length)
`),
).toEqual([0, 0, 0, 0, 0, 0])
})
test("test/built-ins/Object/values/primitive-booleans.js", async () => {
expect(
await value(`
const trueResult = Object.values(true)
const falseResult = Object.values(false)
return [Array.isArray(trueResult), trueResult.length, Array.isArray(falseResult), falseResult.length]
`),
).toEqual([true, 0, true, 0])
})
})
describe("Object.hasOwn Test262 parity", () => {
test("test/built-ins/Object/hasOwn/toobject_{null,undefined}.js", async () => {
expect(await throwsTypeError(`Object.hasOwn(null, 'foo')`)).toBe("TypeError")
expect(await throwsTypeError(`Object.hasOwn(undefined, 'foo')`)).toBe("TypeError")
})
test("test/built-ins/Object/hasOwn/hasown_nonexistent.js", async () => {
expect(await value(`const o = {}; return Object.hasOwn(o, "foo")`)).toBe(false)
})
})
describe("Object.assign Test262 parity", () => {
test("test/built-ins/Object/assign/Source-String.js", async () => {
expect(
await value(`
const target = new Object()
const result = Object.assign(target, "123")
return [result[0], result[1], result[2]]
`),
).toEqual(["1", "2", "3"])
})
test("test/built-ins/Object/assign/Source-Null-Undefined.js", async () => {
expect(
await value(`
const target = new Object()
const result = Object.assign(target, undefined, null)
return result === target
`),
).toBe(true)
})
test("test/built-ins/Object/assign/target-Array.js", async () => {
expect(
await value(`
const target = [7, 8, 9]
let result = Object.assign(target, [1])
const first = [result === target, [...result]]
const sparseArraySource = []
sparseArraySource[2] = 3
result = Object.assign(target, sparseArraySource)
const second = [result === target, [...result]]
result = Object.assign(target, { 4: 0 })
return [...first, ...second, result === target, result.length, result[3] === undefined, result[4]]
`),
).toEqual([true, [1, 8, 9], true, [1, 8, 3], true, 5, true, 0])
})
test("array targets accept only array indexes (deviation from target-Array.js)", async () => {
expect(
await value(`
const target = [7]
const out = []
for (const source of [{ length: 0 }, { x: 1 }, { "1.5": 1 }, { "-0": 1 }, { ["__proto__"]: null }]) {
try { Object.assign(target, source) } catch (error) { out.push(error.name) }
}
return [out, [...target], target.length, Object.keys(target)]
`),
).toEqual([["TypeError", "TypeError", "TypeError", "TypeError", "TypeError"], [7], 1, ["0"]])
})
test("test/built-ins/Object/assign/Target-{Null,Undefined}.js", async () => {
expect(await throwsTypeError(`Object.assign(null, { a: 1 })`)).toBe("TypeError")
expect(await throwsTypeError(`Object.assign(undefined, { a: 1 })`)).toBe("TypeError")
})
test("test/built-ins/Object/assign/Target-Object.js", async () => {
expect(
await value(`
const target = { foo: 1 }
const result = Object.assign(target, { a: 2 })
return [result.foo, result.a]
`),
).toEqual([1, 2])
})
test("test/built-ins/Object/assign/Override.js", async () => {
expect(
await value(`
const target = { a: 1 }
const result = Object.assign(target, "1a2c3", { a: "c" }, undefined, { b: 6 }, null, 125, { a: 5 })
return [Object.keys(result).length, result.a, result[0], result[1], result[2], result[3], result[4], result.b]
`),
).toEqual([7, 5, "1", "a", "2", "c", "3", 6])
})
test("test/built-ins/Object/assign/ObjectOverride-sameproperty.js", async () => {
expect(
await value(`
const target = { a: 1 }
const result = Object.assign(target, { a: 2 }, { a: "c" })
return result.a
`),
).toBe("c")
})
})
+8 -9
View File
@@ -118,9 +118,12 @@ describe("H6: object spread of null/undefined is a no-op", () => {
expect(await value(`const o = { a: 1 }; return { ...o, b: 2 }`)).toEqual({ a: 1, b: 2 })
})
test("spreading an array into an object still errors", async () => {
const err = await error(`return { ...[1,2], a: 1 }`)
expect(err.kind).toBe("InvalidDataValue")
test("spreading an array or string into an object copies index keys, like JS", async () => {
expect(await value(`return { ...[1,2], a: 1 }`)).toEqual({ 0: 1, 1: 2, a: 1 })
expect(await value(`return { ..."ab", ...5, ...true, ...(() => 1), ...new Map([[1, 2]]) }`)).toEqual({
0: "a",
1: "b",
})
})
})
@@ -488,12 +491,8 @@ describe("CodeMode-specific string behavior", () => {
expect(await value(`try { "x".normalize("nope"); return "no" } catch (e) { return e.message }`)).toContain('"NFC"')
})
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",
])
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"])
})
})
+3 -3
View File
@@ -740,7 +740,7 @@ describe("JSDoc signatures in catalogs and search results", () => {
"}",
].join("\n")
const signature = `tools.constrained(input: ${type}): Promise<${type}>`
expect(runtime.catalog()[0]?.signature).toBe(signature)
expect(runtime.catalog[0]?.signature).toBe(signature)
const result = await Effect.runPromise(runtime.execute('return search({ query: "tools.constrained" })'))
expect(result.ok).toBe(true)
if (!result.ok) throw new Error("search failed")
@@ -796,7 +796,7 @@ describe("JSDoc signatures in catalogs and search results", () => {
})
test("the catalog uses the same JSDoc signatures as search", async () => {
const catalog = runtime.catalog()
const catalog = runtime.catalog
const github = (await search("list issues repository")).items.find(
({ path }) => path === "tools.github.list_issues",
)!
@@ -824,7 +824,7 @@ describe("non-identifier tool paths", () => {
const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } })
test("catalog signatures use bracket notation for dashed tool names", () => {
expect(runtime.catalog()[0]?.signature).toBe(
expect(runtime.catalog[0]?.signature).toBe(
'tools.context7["resolve-library-id"](input: {\n query: string,\n libraryName: string,\n}): Promise<unknown>',
)
})
+1 -1
View File
@@ -1130,7 +1130,7 @@ describe("CodeMode values at intra-CodeMode checkpoints", () => {
const diagnostic = await error(`return Object.keys(Promise.resolve({ a: 1 }))`)
expect(diagnostic.kind).toBe("InvalidDataValue")
expect(diagnostic.message).toContain("await")
expect((await error(`return Object.keys(Math)`)).kind).toBe("InvalidDataValue")
expect(await value(`return Object.keys(Math)`)).toEqual([])
})
test("Object.assign keeps Maps usable", async () => {
@@ -0,0 +1,179 @@
/**
* 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")
})
})
+10 -10
View File
@@ -26,7 +26,7 @@ describe("dotted tool names", () => {
const runtime = CodeMode.make({ tools: { api: { "issues.list": echo("List issues", "listed") } } })
test("a dotted name becomes nested namespaces in the catalog", () => {
const catalog = runtime.catalog()
const catalog = runtime.catalog
expect(catalog).toHaveLength(1)
expect(catalog[0]?.path).toBe("api.issues.list")
expect(catalog[0]?.signature).toStartWith("tools.api.issues.list(")
@@ -51,7 +51,7 @@ describe("dotted tool names", () => {
test("a top-level dotted name nests from the root", async () => {
const flat = CodeMode.make({ tools: { "issues.list": echo("List issues", "flat") } })
expect(flat.catalog()[0]?.path).toBe("issues.list")
expect(flat.catalog[0]?.path).toBe("issues.list")
expect(await value(flat, `return await tools.issues.list({})`)).toBe("flat")
})
@@ -85,7 +85,7 @@ describe("callable namespaces", () => {
test("a path can hold a tool and child tools at once", async () => {
expect(await value(runtime, `return await tools.issues({})`)).toBe("all")
expect(await value(runtime, `return await tools.issues.list({})`)).toBe("list")
expect(runtime.catalog().map((tool) => tool.path)).toEqual(["issues", "issues.list"])
expect(runtime.catalog.map((tool) => tool.path)).toEqual(["issues", "issues.list"])
})
test("a callable namespace enumerates its children", async () => {
@@ -145,7 +145,7 @@ describe("tool input diagnostics", () => {
test("an empty-input tool advertises () and runs with zero arguments", async () => {
const empty = CodeMode.make({ tools: { ping: echo("Ping", "pong") } })
expect(empty.catalog()[0]?.signature).toBe("tools.ping(): Promise<string>")
expect(empty.catalog[0]?.signature).toBe("tools.ping(): Promise<string>")
expect(await value(empty, `return await tools.ping()`)).toBe("pong")
})
})
@@ -160,7 +160,7 @@ describe("blocked member names on tool paths", () => {
})
test("tools may use blocked member names because path segments never touch real properties", async () => {
expect(runtime.catalog().map((tool) => tool.path)).toEqual(["issues.constructor", "nested.__proto__", "prototype"])
expect(runtime.catalog.map((tool) => tool.path)).toEqual(["issues.constructor", "nested.__proto__", "prototype"])
expect(await value(runtime, `return await tools.prototype({})`)).toBe("proto")
expect(await value(runtime, `return await tools.issues.constructor({})`)).toBe("ctor")
expect(await value(runtime, `return await tools["issues.constructor"]({})`)).toBe("ctor")
@@ -172,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")
@@ -0,0 +1,242 @@
/**
* Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75:
* - test/language/statements/variable/S12.2_A1.js
* - test/language/statements/variable/S12.2_A3.js
* - test/language/statements/variable/S12.2_A6_T1.js
* - test/language/statements/variable/S12.2_A7.js
* - test/language/statements/variable/S12.2_A10.js
* - test/language/statements/variable/S12.2_A12.js
* - test/language/block-scope/shadowing/hoisting-var-declarations-out-of-blocks.js
* - test/language/block-scope/shadowing/catch-parameter-shadowing-var-variable.js
* - test/language/statements/for/head-var-bound-names-in-stmt.js
* - test/language/statements/function/scope-paramsbody-var-open.js
* - test/language/statements/function/scope-paramsbody-var-close.js
*
* Copyright 2009 the Sputnik authors. All rights reserved.
* Copyright (C) 2011, 2016 the V8 project authors. All rights reserved.
* Test262 portions are governed by the BSD license in LICENSE.test262.
*
* Files that observe `var` through `eval`, `this`, `delete`, or the global object (S12.2_A2, A5, A9,
* A11, `scope-*-none.js`, `scope-param-elem-*.js`) have no analogue here and are not ported.
*/
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { CodeMode } from "../src/index.js"
const value = async (code: string) => {
const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} }))
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
return result.value
}
describe("var hoisting Test262 parity", () => {
test("test/language/statements/variable/S12.2_A1.js: use before declaration reads undefined", async () => {
expect(
await value(`
__x = __x
__y = __x ? "good fellow" : "liar"
__z = __z === __x ? 1 : 0
let unknown
try { __something__undefined = __something__undefined } catch (error) { unknown = error.name }
const before = [__y, __z, unknown]
var __x, __y = true, __z = __y ? "smeagol" : "golum"
return [...before, __y, __z]
`),
).toEqual(["liar", 1, "ReferenceError", true, "smeagol"])
})
test("test/language/statements/variable/S12.2_A3.js: nested functions redeclare or assign", async () => {
expect(
await value(`
var __var = "OUT"
const inner = (function () {
var __var = "IN"
;(function () { __var = "INNER_SPACE" })()
;(function () { var __var = "INNER_SUN" })()
return __var
})()
const after = __var
const assigned = (function () {
__var = "IN"
;(function () { __var = "INNERED" })()
;(function () { var __var = "INNAGER" })()
return __var
})()
return [inner, after, assigned, __var]
`),
).toEqual(["INNER_SPACE", "OUT", "INNERED", "INNERED"])
})
test("test/language/statements/variable/S12.2_A6_T1.js: var inside try and catch is hoisted", async () => {
expect(
await value(`
intry__var = intry__var
incatch__var = incatch__var
try { var intry__var } catch (e) { var incatch__var }
return [typeof intry__var, typeof incatch__var]
`),
).toEqual(["undefined", "undefined"])
})
test("test/language/statements/variable/S12.2_A7.js: var after break inside for is hoisted", async () => {
expect(
await value(`
infor_var = infor_var
for (;;) { break; var infor_var }
return typeof infor_var
`),
).toBe("undefined")
})
test("test/language/statements/variable/S12.2_A10.js: var in for head is hoisted", async () => {
expect(
await value(`
__ind = __ind
for (var __ind; ; ) { break }
return typeof __ind
`),
).toBe("undefined")
})
test("test/language/statements/variable/S12.2_A12.js: var in do-while body is hoisted", async () => {
expect(
await value(`
x = x
do var x; while (false)
return typeof x
`),
).toBe("undefined")
})
test("test/language/block-scope/shadowing/hoisting-var-declarations-out-of-blocks.js", async () => {
expect(
await value(`
function fn() {
{ var x = 1; var y }
return [x, typeof y]
}
return fn()
`),
).toEqual([1, "undefined"])
})
test("test/language/block-scope/shadowing/catch-parameter-shadowing-var-variable.js", async () => {
expect(
await value(`
function fn() {
var a = 1
let caught
try { throw "stuff3" } catch (a) { caught = a }
return [caught, a]
}
return fn()
`),
).toEqual(["stuff3", 1])
})
test("test/language/statements/for/head-var-bound-names-in-stmt.js: redeclaring the head var in the body", async () => {
expect(
await value(`
var iterCount = 0
var first = true
for (var x; first; first = false) {
var x
iterCount += 1
}
return iterCount
`),
).toBe(1)
})
test("test/language/statements/function/scope-paramsbody-var-open.js: parameter defaults see the outer var", async () => {
expect(
await value(`
var x = "outside"
var probeParams, probeBody
function f(_ = probeParams = function () { return x }) {
var x = "inside"
probeBody = function () { return x }
}
f()
return [probeParams(), probeBody()]
`),
).toEqual(["outside", "inside"])
})
test("test/language/statements/function/scope-paramsbody-var-close.js: body var does not leak out", async () => {
expect(
await value(`
var probe
function f(_ = null) {
var x = "inside"
probe = function () { return x }
}
f()
var x = "outside"
return [probe(), x]
`),
).toEqual(["inside", "outside"])
})
})
describe("var semantics beyond Test262", () => {
test("redeclaration and block-level var assign the one function-scoped binding", async () => {
expect(await value(`var a = 1; var a = 2; { var a = 3 } return a`)).toBe(3)
expect(await value(`var q = 1; { let q = 2 } return q`)).toBe(1)
})
test("var loop counters are shared by closures, let counters are not", async () => {
expect(
await value(`
const byVar = []
for (var i = 0; i < 3; i++) byVar.push(() => i)
const byLet = []
for (let j = 0; j < 3; j++) byLet.push(() => j)
return [byVar.map((f) => f()), byLet.map((f) => f())]
`),
).toEqual([
[3, 3, 3],
[0, 1, 2],
])
})
test("for...in and for...of var heads survive the loop", async () => {
expect(await value(`for (var k in { a: 1 }) {} for (var [p, q] of [[1, 2]]) {} return [k, p, q]`)).toEqual([
"a",
1,
2,
])
})
test("var and function declarations of the same name share a binding", async () => {
expect(await value(`var fn = 1; function fn() {} return typeof fn`)).toBe("number")
expect(await value(`function fn() {} var fn; return typeof fn`)).toBe("function")
expect(await value(`function h() { var fn = 1; function fn() {} return typeof fn } return h()`)).toBe("number")
})
test("a var named after a parameter keeps the argument until assigned", async () => {
expect(await value(`function f(a) { var a; return a } return f(7)`)).toBe(7)
expect(await value(`function f(a) { var a = 2; return a } return f(7)`)).toBe(2)
})
test("var does not hoist across function boundaries", async () => {
expect(await value(`return [typeof b, (() => { var b = 1; return b })()]; var b`)).toEqual(["undefined", 1])
expect(
await value(
`function outer() { var o = 1; function inner() { var o = 2; return o } return [inner(), o] } return outer()`,
),
).toEqual([2, 1])
})
test("switch cases, labels, and generators hoist var", async () => {
expect(await value(`switch (1) { case 1: var s = 9 } label: { var lb = 1 } return [s, lb]`)).toEqual([9, 1])
expect(await value(`function* gen() { var t = 1; yield t; var t = 2; yield t } return [...gen()]`)).toEqual([1, 2])
})
})
describe("switch case function hoisting", () => {
test("function declarations are visible across all cases before their statement runs", async () => {
expect(await value(`switch (1) { case 1: return foo(); function foo() { return "hoisted" } }`)).toBe("hoisted")
expect(await value(`switch (2) { case 1: function foo() { return "a" } break; case 2: return foo() }`)).toBe("a")
})
})
+169
View File
@@ -0,0 +1,169 @@
/**
* 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])
})
})
+1 -1
View File
@@ -163,7 +163,7 @@ function mapProviderOptions(settings: Readonly<Record<string, unknown>>, exclude
function mapBedrockMantle(input: MapInput, baseSettings: Readonly<Record<string, unknown>>): Mapping | undefined {
const settings = input.settings
const chat = input.modelID === "openai.gpt-oss-safeguard-20b" || input.modelID === "openai.gpt-oss-safeguard-120b"
const chat = input.modelID.includes("gpt-oss")
return {
package: `@opencode/ai/providers/amazon-bedrock/mantle/${chat ? "chat" : "responses"}`,
settings: {
+1 -1
View File
@@ -166,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,
+2
View File
@@ -45,6 +45,7 @@ import m42 from "./migration/20260812181746_session_inbox.js"
import m43 from "./migration/20260812213948_worktree.js"
import m44 from "./migration/20260819222447_session_viewed_state.js"
import m45 from "./migration/20260823191254_nullable_workspace_binding.js"
import m46 from "./migration/20260910120000_clear_v1_session_permission.js"
export const migrations = [
m00,
@@ -93,4 +94,5 @@ export const migrations = [
m43,
m44,
m45,
m46,
] satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,13 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260910120000_clear_v1_session_permission",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`UPDATE \`session_v2\` SET \`permission\` = NULL;`)
})
},
}
export default migration
@@ -600,7 +600,7 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
id, ${projectID}, workspace_id, parent_id, slug, directory, path, title, version, share_url,
summary_additions, summary_deletions, summary_files, summary_diffs, metadata, cost,
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
revert, permission, agent, model, time_created, time_updated, time_compacting, time_archived
revert, NULL, agent, model, time_created, time_updated, time_compacting, time_archived
FROM session
WHERE id = ${nextID.id}
`)
+1 -1
View File
@@ -203,7 +203,7 @@ function variants(remote: UsableModel, messages: boolean): Model.Info["variants"
settings: {
thinking: {
type: "adaptive",
...(remote.id.includes("opus-4.7") ? { display: "summarized" } : {}),
display: "summarized",
},
effort,
},
+1 -1
View File
@@ -154,7 +154,7 @@ const layer = Layer.effect(
const session = yield* sessions.get(sessionID)
if (!session) return yield* new SessionErrors.NotFoundError({ sessionID })
const agent = yield* agents.resolve(agentID ?? session.agent)
return agent?.permissions ?? missingAgentPermissions
return merge(agent?.permissions ?? missingAgentPermissions, session.permissions ?? [])
})
function denied(input: Pick<Request, "action" | "resources">, rules: Permission.Ruleset) {
+3
View File
@@ -404,6 +404,7 @@ export const make = Effect.fn("PluginHost.make")(function* (
: Effect.fail(new Error(`Permission request not found: ${input.requestID}`)),
),
),
rules: sessions.setPermissions,
},
plugin: {
list: () => response(plugin.list()),
@@ -509,6 +510,8 @@ export const make = Effect.fn("PluginHost.make")(function* (
title: input?.title,
agent: input?.agent,
model: input?.model,
metadata: input?.metadata,
permissions: input?.permissions,
location:
input?.location ?? Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }),
}),
+5 -3
View File
@@ -54,7 +54,7 @@ function make(
return define({
id,
effect: Effect.fn(`OptimizePlugin.${id}`)(function* (ctx) {
yield* ctx.session.hook("context", (event) =>
const hook = (event: SessionHooks["context"]) =>
Effect.gen(function* () {
const model =
(yield* ctx.catalog.model.list()).data.find(
@@ -67,8 +67,10 @@ function make(
const system = event.system[0]
if (!system) return
event.system[0] = { ...system, text: SessionSystemPrompt.render(template, Object.keys(event.tools)) }
}).pipe(Effect.catch(() => Effect.void)),
)
}).pipe(Effect.catch(() => Effect.void))
yield* ctx.session.hook("context", hook)
yield* ctx.session.hook("compaction", hook)
yield* ctx.session.hook("generate", hook)
}),
})
}
+1
View File
@@ -101,6 +101,7 @@ 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
})
}
})
+6 -3
View File
@@ -1,6 +1,7 @@
export * as WarmingPlugin from "./warming.js"
import { define } from "@opencode/plugin/effect/plugin"
import type { SessionHooks } from "@opencode/plugin/effect/session"
import type { Session } from "@opencode/schema/session"
import { Clock, Duration, Effect, Scope } from "effect"
import { Config } from "../config.js"
@@ -54,7 +55,7 @@ export const Plugin = define({
},
)
yield* ctx.session.hook("context", (event) =>
const hook = (event: SessionHooks["context"]) =>
Effect.gen(function* () {
const active = sessions.get(event.sessionID)
const settings = yield* loadSettings()
@@ -95,7 +96,9 @@ export const Plugin = define({
),
Effect.forkIn(scope),
)
}),
)
})
yield* ctx.session.hook("context", hook)
yield* ctx.session.hook("compaction", hook)
yield* ctx.session.hook("generate", hook)
}),
})
+10 -2
View File
@@ -18,6 +18,7 @@ import { SessionMessageTable } from "./session/sql.js"
import { SessionSchema } from "./session/schema.js"
import { RelativePath } from "./schema.js"
import { Agent } from "@opencode/schema/agent"
import type { Permission } from "@opencode/schema/permission"
import { App } from "./app.js"
import { Slug } from "./util/slug.js"
import path from "path"
@@ -81,6 +82,7 @@ type CreateBaseInput = {
agent?: Agent.ID
model?: Model.Ref
metadata?: SessionSchema.Metadata
permissions?: Permission.Ruleset
}
type CreateInput = CreateBaseInput &
({ location: Location.Ref; parentID?: never } | { parentID: SessionSchema.ID; location?: never })
@@ -157,6 +159,10 @@ export interface Interface {
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: Agent.ID }) => Effect.Effect<void, NotFoundError>
readonly switchModel: (input: { sessionID: SessionSchema.ID; model: Model.Ref }) => Effect.Effect<void, NotFoundError>
readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect<void, NotFoundError>
readonly setPermissions: (input: {
sessionID: SessionSchema.ID
permissions: Permission.Ruleset
}) => Effect.Effect<void, NotFoundError>
readonly move: SessionMove.Interface["move"]
readonly prompt: (
input: Parameters<Session.Handle["prompt"]>[0] & { sessionID: SessionSchema.ID },
@@ -248,9 +254,10 @@ const layer = Layer.effect(
subpath: RelativePath.make(path.relative(project.directory, location.directory).replaceAll("\\", "/")),
title: input.title,
agent: input.agent,
// Children inherit metadata the way they inherit location, so
// host policies that read it treat the family uniformly.
// Children inherit metadata and permissions the way they inherit
// location, so host policies that read them treat the family uniformly.
metadata: input.metadata ?? parent?.metadata,
permissions: input.permissions ?? parent?.permissions,
model: input.model
? {
id: Model.ID.make(input.model.id),
@@ -387,6 +394,7 @@ const layer = Layer.effect(
switchAgent: (input) => sessions.forSession(input.sessionID).switchAgent(input),
switchModel: (input) => sessions.forSession(input.sessionID).switchModel(input),
rename: (input) => sessions.forSession(input.sessionID).rename(input),
setPermissions: (input) => sessions.forSession(input.sessionID).setPermissions(input),
move: moves.move,
compact: (input) => sessions.forSession(input.sessionID).compact(input),
wait: (sessionID) => sessions.forSession(sessionID).wait(),
+45 -9
View File
@@ -11,6 +11,7 @@ import {
Message,
type ContentPart,
} from "@opencode/ai"
import type { SessionCompactionResult } from "@opencode/plugin/effect/session"
import { SessionError } from "@opencode/schema/session-error"
import { Context, Effect, Layer, Stream } from "effect"
import { Bus } from "../bus.js"
@@ -402,6 +403,36 @@ export const layer = Layer.effect(
recent,
inputID: input.inputID,
})
const supplied = Effect.fn("SessionCompaction.supplied")(function* (
input: ExecuteInput,
result: SessionCompactionResult,
recent: string,
) {
const context = input.context
const usage = result.tokens
? { tokens: result.tokens, cost: SessionUsage.calculateCost(context.model.cost, result.tokens) }
: undefined
if (usage)
yield* bus.publish(SessionEvent.UsageRecorded, {
sessionID: context.session.id,
source: "compaction",
...usage,
})
yield* bus.publish(
SessionEvent.Compaction.Ended,
{
sessionID: context.session.id,
reason: input.reason,
model: context.model.ref,
providerState: result.providerState,
text: result.summary,
recent,
...usage,
},
{ metadata: result.metadata },
)
return { status: "completed" as const }
})
// Manual controls settle through the inbox; only automatic work needs a durable interruption record.
const interrupted = (input: ExecuteInput) =>
input.reason === "auto"
@@ -415,7 +446,6 @@ export const layer = Layer.effect(
const compactionRequest = (
input: ExecuteInput,
messages: readonly SessionMessage.Info[],
prompt: Message[],
webSocket?: "session",
) => {
const context = input.context
@@ -435,7 +465,6 @@ export const layer = Layer.effect(
messages: [
...transcript.messages,
...(input.instructionUpdate ? [Message.system(input.instructionUpdate)] : []),
...prompt,
],
webSocket,
})
@@ -455,7 +484,11 @@ 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, "")
}
const request = prepared.request
const provenance = SessionProviderContext.provenance(context.model)
if (!provenance) return yield* reject("Provider compaction requires a stable, configured endpoint")
@@ -568,9 +601,12 @@ 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, [
Message.user(buildPrompt(previous !== undefined, legacy)),
])
const prepared = yield* compactionRequest(input, history.messages)
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,
@@ -578,10 +614,10 @@ export const layer = Layer.effect(
hook: prepared.retry,
})
for (const request of [
prepared.request,
LLMRequest.update(prepared.request, {
first,
LLMRequest.update(first, {
messages: [
...prepared.request.messages,
...first.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.",
),
+2 -1
View File
@@ -1,6 +1,7 @@
export * as SessionContext from "./context.js"
import { Model } from "@opencode/schema/model"
import { Permission } from "../permission.js"
import { Context, Effect, Layer } from "effect"
import { Agent } from "../agent.js"
import { Catalog } from "../catalog.js"
@@ -129,7 +130,7 @@ const layer = Layer.effect(
if (!agent.info) return yield* new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id })
const loaded = yield* Effect.all(
{
tools: registry.snapshot(agent.info.permissions),
tools: registry.snapshot(Permission.merge(agent.info.permissions, session.permissions ?? [])),
builtins: builtins.load(sessionID),
discovery: discovery.load(),
skills: skillInstructions.load(agent),
+1
View File
@@ -50,6 +50,7 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
}),
subpath: row.path ? RelativePath.make(row.path) : undefined,
metadata: row.metadata ?? undefined,
permissions: row.permission ?? undefined,
revert: row.revert ? decodeRevert(row.revert) : undefined,
outcome: row.idle_outcome ?? undefined,
time: {
@@ -116,6 +116,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
)
}),
"session.renamed": () => Effect.void,
"session.permissions.updated": () => Effect.void,
"session.deleted": () => Effect.void,
"session.forked": () => Effect.void,
"session.inbox.delivered": () => Effect.void,
@@ -409,6 +410,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
yield* adapter.updateCompaction({
...current,
status: "completed",
metadata: event.metadata ? { ...current.metadata, ...event.metadata } : current.metadata,
reason: event.data.reason,
model: event.data.model,
providerState: event.data.providerState,
+16 -8
View File
@@ -11,7 +11,14 @@ import {
SystemPart,
} from "@opencode/ai"
import type { StreamOptions } from "@opencode/ai/route"
import type { SessionContext, SessionRequest, SessionRequestKind, SessionTitle } from "@opencode/plugin/effect/session"
import type {
SessionCompaction,
SessionContext,
SessionGenerate,
SessionRequest,
SessionRequestKind,
SessionTitle,
} from "@opencode/plugin/effect/session"
import type { Agent } from "@opencode/schema/agent"
import type { Model } from "@opencode/schema/model"
import type { Content } from "@opencode/schema/tool"
@@ -178,8 +185,8 @@ type Definitions = PluginHooks.Domains["session"]["context"]["tools"]
/** Builds the model request for each session flow. Each entry runs its own plugin hook. */
export interface Interface {
readonly primary: (input: Input) => Effect.Effect<Prepared<SessionContext>>
readonly compaction: (input: Input) => Effect.Effect<Prepared<SessionContext>>
readonly generate: (input: Input) => Effect.Effect<Prepared<SessionContext>>
readonly compaction: (input: Input) => Effect.Effect<Prepared<SessionCompaction>>
readonly generate: (input: Input) => Effect.Effect<Prepared<SessionGenerate>>
readonly title: (input: Input) => Effect.Effect<Prepared<SessionTitle>>
}
@@ -342,13 +349,14 @@ export const layer = Layer.effect(
}
})
const context = (agent: Agent.ID) => (draft: SessionRequest, tools: Definitions) =>
hooks.trigger("session", "context", { ...draft, agent, tools })
const agentHook =
(name: "context" | "compaction" | "generate", agent: Agent.ID) => (draft: SessionRequest, tools: Definitions) =>
hooks.trigger("session", name, { ...draft, agent, tools })
return Service.of({
primary: (input) => prepare("primary", input, context(input.agent)),
generate: (input) => prepare("generate", input, context(input.agent)),
compaction: (input) => prepare("compaction", input, context(input.agent)),
primary: (input) => prepare("primary", input, agentHook("context", input.agent)),
compaction: (input) => prepare("compaction", input, agentHook("compaction", input.agent)),
generate: (input) => prepare("generate", input, agentHook("generate", input.agent)),
title: (input) => prepare("title", input, (draft) => hooks.trigger("session", "title", draft)),
})
}),
+10
View File
@@ -160,6 +160,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
agent: parent.agent,
model: parent.model,
metadata: parent.metadata,
permission: parent.permission,
version: parent.version,
cost: 0,
tokens_input: 0,
@@ -450,6 +451,7 @@ const layer = Layer.effectDiscard(
agent: event.data.agent,
model: event.data.model,
metadata: event.data.metadata,
permission: event.data.permissions,
version: event.data.version,
time_created: event.created,
time_updated: event.created,
@@ -571,6 +573,14 @@ const layer = Layer.effectDiscard(
.run()
.pipe(Effect.orDie),
)
yield* bus.project(SessionEvent.PermissionsUpdated, (event) =>
db
.update(SessionTable)
.set({ permission: event.data.permissions, time_updated: event.created })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie),
)
yield* bus.project(SessionEvent.Viewed, (event) => {
const idle = event.data.idle
return db
+11
View File
@@ -3,6 +3,7 @@ export * as Session from "./session.js"
import { DateTime, Effect, Fiber, Scope } from "effect"
import type { Agent } from "@opencode/schema/agent"
import type { Model } from "@opencode/schema/model"
import type { Permission } from "@opencode/schema/permission"
import { Event } from "@opencode/schema/event"
import { FSUtil } from "@opencode/util/fs-util"
import { Bus } from "../bus.js"
@@ -72,6 +73,13 @@ export const make = Effect.fn("Session.make")(function* () {
yield* get(sessionID)
yield* bus.publish(SessionEvent.Renamed, { sessionID, title: input.title })
})
const setPermissions = Effect.fn("Session.setPermissions")(function* (
sessionID: SessionSchema.ID,
input: { permissions: Permission.Ruleset },
) {
yield* get(sessionID)
yield* bus.publish(SessionEvent.PermissionsUpdated, { sessionID, permissions: input.permissions })
})
const switchAgent = Effect.fn("Session.switchAgent")(function* (
sessionID: SessionSchema.ID,
input: { agent: Agent.ID },
@@ -334,6 +342,7 @@ export const make = Effect.fn("Session.make")(function* () {
message,
view,
rename,
setPermissions,
switchAgent,
switchModel,
inbox,
@@ -356,6 +365,7 @@ export const make = Effect.fn("Session.make")(function* () {
const message = operations.message.bind(undefined, sessionID)
const view = operations.view.bind(undefined, sessionID)
const rename = operations.rename.bind(undefined, sessionID)
const setPermissions = operations.setPermissions.bind(undefined, sessionID)
const switchAgent = operations.switchAgent.bind(undefined, sessionID)
const switchModel = operations.switchModel.bind(undefined, sessionID)
const inbox = operations.inbox.bind(undefined, sessionID)
@@ -381,6 +391,7 @@ export const make = Effect.fn("Session.make")(function* () {
message,
view,
rename,
setPermissions,
switchAgent,
switchModel,
inbox,
+2 -2
View File
@@ -5,7 +5,7 @@ import { ProjectTable } from "../project/sql.js"
import type { SessionMessage } from "./message.js"
import type { SessionInbox } from "./inbox.js"
import type { FileDiff } from "@opencode/schema/file-diff"
import type { PermissionV1 } from "@opencode/schema/permission-v1"
import type { Permission } from "@opencode/schema/permission"
import type { Project } from "@opencode/schema/project"
import type { SessionSchema } from "./schema.js"
import type { Workspace } from "@opencode/schema/workspace"
@@ -49,7 +49,7 @@ export const SessionTable = sqliteTable(
tokens_cache_read: integer().notNull().default(0),
tokens_cache_write: integer().notNull().default(0),
revert: text({ mode: "json" }).$type<Session.Revert | RevertV1>(),
permission: text({ mode: "json" }).$type<PermissionV1.Ruleset>(),
permission: text({ mode: "json" }).$type<Permission.Ruleset>(),
agent: text(),
model: text({ mode: "json" }).$type<{
id: string
+1
View File
@@ -103,6 +103,7 @@ const layer = Layer.effect(
agent: input.data.info.agent,
model: input.data.info.model,
metadata: input.data.info.metadata,
permissions: input.data.info.permissions,
},
{
location: input.location,
+12 -1
View File
@@ -99,8 +99,19 @@ export const layer = Layer.effect(
},
)
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
const output = () => {
if (result.structured !== undefined) return result.structured
if (text === "") return null
// Agents assume JSON returned as text is already an object, so parse it when the server declares no schema.
if (tool.outputSchema === undefined && (text.startsWith("{") || text.startsWith("["))) {
try {
return JSON.parse(text)
} catch {}
}
return text
}
return {
output: result.structured ?? (text === "" ? null : text),
output: output(),
...(content.length === 0 ? {} : { content }),
}
}).pipe(
+3 -3
View File
@@ -175,7 +175,7 @@ describe("AISDKNative", () => {
settings: { region: "us-east-1" },
})
expect(map("@ai-sdk/amazon-bedrock/mantle", { region: "us-east-1" }, "openai.gpt-oss-120b")).toEqual({
package: "@opencode/ai/providers/amazon-bedrock/mantle/responses",
package: "@opencode/ai/providers/amazon-bedrock/mantle/chat",
settings: { region: "us-east-1" },
})
})
@@ -287,7 +287,7 @@ describe("AISDKNative", () => {
}
expect(map("@ai-sdk/amazon-bedrock/mantle", settings, "openai.gpt-oss-120b")).toEqual({
package: "@opencode/ai/providers/amazon-bedrock/mantle/responses",
package: "@opencode/ai/providers/amazon-bedrock/mantle/chat",
settings: {
apiKey: "token",
baseURL: "https://mantle.test/v1",
@@ -336,7 +336,7 @@ describe("AISDKNative", () => {
"openai.gpt-oss-120b",
),
).toEqual({
package: "@opencode/ai/providers/amazon-bedrock/mantle/responses",
package: "@opencode/ai/providers/amazon-bedrock/mantle/chat",
settings: {
credentials: {
accessKeyId: "key",
+74
View File
@@ -324,6 +324,32 @@ const mcp = Layer.mock(Mcp.Service, {
description: "Status",
inputSchema: { type: "object", properties: {} },
}),
new Mcp.Tool({
server: Mcp.ServerName.make("demo"),
name: "issues",
description: "Returns JSON as text",
inputSchema: { type: "object", properties: {} },
}),
new Mcp.Tool({
server: Mcp.ServerName.make("demo"),
name: "count",
description: "Returns a number as text",
inputSchema: { type: "object", properties: {} },
}),
new Mcp.Tool({
server: Mcp.ServerName.make("demo"),
name: "typed",
description: "Declares a string output and returns JSON as text",
inputSchema: { type: "object", properties: {} },
outputSchema: { type: "string" },
}),
new Mcp.Tool({
server: Mcp.ServerName.make("direct"),
name: "issues",
codemode: false,
description: "Returns JSON as text",
inputSchema: { type: "object", properties: {} },
}),
new Mcp.Tool({
server: Mcp.ServerName.make("direct"),
name: "lookup",
@@ -374,6 +400,20 @@ const mcp = Layer.mock(Mcp.Service, {
isError: false,
content: [{ type: "text", text: "hello" }],
})
if (input.name === "issues" || input.name === "typed")
return new Mcp.ToolResult({
server: Mcp.ServerName.make(input.server),
tool: input.name,
isError: false,
content: [{ type: "text", text: '{"issues":[{"id":1}]}' }],
})
if (input.name === "count")
return new Mcp.ToolResult({
server: Mcp.ServerName.make(input.server),
tool: input.name,
isError: false,
content: [{ type: "text", text: "42" }],
})
return new Mcp.ToolResult({
server: Mcp.ServerName.make(input.server),
tool: input.name,
@@ -1943,6 +1983,7 @@ it.effect("advertises MCP output schemas to Code Mode", () =>
expect(toolSet.definitions.map((tool) => tool.name)).toEqual([
"direct_fail",
"direct_issues",
"direct_lookup",
"direct_media",
"execute",
@@ -2033,6 +2074,39 @@ it.effect("returns content-only MCP results through Code Mode", () =>
}),
)
it.effect("parses JSON text results from MCP tools without an output schema", () =>
Effect.gen(function* () {
assertion = yield* Deferred.make<Permission.AssertInput>()
decision = Effect.void
const registry = yield* Tool.Service
const registration = yield* McpTool.Service
yield* registration.flush
const toolSet = yield* registry.snapshot()
const run = (code: string) =>
toolSet
.execute({
sessionID: Session.ID.make("ses_mcp_json_text"),
...toolIdentity,
call: { type: "tool-call", id: `call_${code.length}`, name: "execute", input: { code } },
})
.pipe(Effect.map((execution) => execution.output.output))
expect(yield* run("return (await tools.demo.issues({})).issues[0].id")).toBe("1")
expect(yield* run("return typeof (await tools.demo.count({}))")).toBe("string")
expect(yield* run("return typeof (await tools.demo.typed({}))")).toBe("string")
// Outside Code Mode the content the model reads is the original text.
expect(
yield* toolSet.execute({
sessionID: Session.ID.make("ses_mcp_json_text"),
...toolIdentity,
call: { type: "tool-call", id: "call_direct_issues", name: "direct_issues", input: {} },
}),
).toMatchObject({ output: { issues: [{ id: 1 }] }, content: [{ type: "text", text: '{"issues":[{"id":1}]}' }] })
}),
)
it.effect("advertises MCP tools directly when Code Mode is disabled for the server", () =>
Effect.gen(function* () {
const registry = yield* Tool.Service
+5 -5
View File
@@ -169,19 +169,19 @@ describe("ModelResolver", () => {
),
)
it.effect("maps Bedrock Mantle models to native Responses and safeguards to Chat", () =>
it.effect("maps Bedrock Mantle GPT-OSS models to Chat and other models to Responses", () =>
Effect.gen(function* () {
const credential = Credential.Key.make({ type: "key", key: "secret" })
const responses = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"), {
modelID: "openai.gpt-oss-120b",
modelID: "openai.gpt-5.5",
settings: { region: "us-east-2" },
}),
credential,
)
const chat = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"), {
modelID: "openai.gpt-oss-safeguard-20b",
modelID: "openai.gpt-oss-20b",
settings: { region: "us-east-2" },
}),
credential,
@@ -1041,7 +1041,7 @@ describe("ModelResolver", () => {
["@ai-sdk/amazon-bedrock", "@opencode/ai/providers/amazon-bedrock", "api-model"],
[
"@ai-sdk/amazon-bedrock/mantle",
"@opencode/ai/providers/amazon-bedrock/mantle/responses",
"@opencode/ai/providers/amazon-bedrock/mantle/chat",
"openai.gpt-oss-120b",
],
["@ai-sdk/azure", "@opencode/ai/providers/azure/responses", "api-model"],
@@ -1271,7 +1271,7 @@ describe("ModelResolver", () => {
expect(bedrock.route.id).toBe("bedrock-converse")
expect(bedrock.route.defaults.generation).toEqual({ topP: 0.8 })
expect(bedrock.route.defaults.http?.body).toEqual({ serviceTier: { type: "priority" } })
expect(mantle.route.id).toBe("bedrock-mantle-responses")
expect(mantle.route.id).toBe("bedrock-mantle-chat")
expect(mantle.route.defaults.generation).toEqual({ topP: 0.6 })
}),
)
+28
View File
@@ -224,6 +224,34 @@ describe("Permission", () => {
}),
)
it.effect("merges session rules after agent rules and before saved approvals", () =>
Effect.gen(function* () {
yield* setup([{ action: "*", resource: "*", effect: "allow" }])
const { db } = yield* Database.Service
const service = yield* Permission.Service
const setSession = (permission: Permission.Ruleset) =>
db
.update(SessionTable)
.set({ permission })
.where(eq(SessionTable.id, Session.ID.make("ses_test")))
.run()
.pipe(Effect.orDie)
yield* setSession([{ action: "edit", resource: "/original/**", effect: "deny" }])
expect(yield* service.ask(assertion({ action: "edit", resources: ["/original/src/index.ts"] }))).toMatchObject({
effect: "deny",
})
yield* setRules([])
const saved = yield* PermissionSaved.Service
yield* saved.add({ projectID: Project.ID.global, action: "bash", resources: ["pwd"] })
yield* setSession([{ action: "bash", resource: "*", effect: "deny" }])
expect(yield* service.ask(assertion({ action: "bash", resources: ["pwd"] }))).toMatchObject({ effect: "deny" })
yield* setSession([{ action: "bash", resource: "*", effect: "ask" }])
expect(yield* service.ask(assertion({ action: "bash", resources: ["pwd"] }))).toMatchObject({ effect: "allow" })
}),
)
it.effect("uses saved bash approvals while preserving configured deny precedence", () =>
Effect.gen(function* () {
yield* setup()
+1
View File
@@ -108,6 +108,7 @@ export function host(overrides: Overrides = {}): Plugin.Context {
list: () => Effect.die("unused permission.list"),
get: () => Effect.die("unused permission.get"),
reply: () => Effect.die("unused permission.reply"),
rules: () => Effect.die("unused permission.rules"),
},
plugin: overrides.plugin ?? {
list: () => Effect.die("unused plugin.list"),
@@ -69,7 +69,7 @@ describe("XAIPlugin", () => {
}),
)
it.effect("keeps xAI Responses WebSockets opt-in", () =>
it.effect("enables xAI Responses WebSockets", () =>
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).toBeUndefined()
expect(model?.websocket).toBe(true)
}),
)
})
@@ -11,6 +11,7 @@ import { SessionCompaction } from "@opencode/core/session/compaction"
import { SessionEvent } from "@opencode/core/session/event"
import { SessionMessage } from "@opencode/core/session/message"
import { SessionModelRequest } from "@opencode/core/session/model-request"
import { PluginHooks } from "@opencode/core/plugin/hooks"
import { SessionProjector } from "@opencode/core/session/projector"
import { SessionRunnerModel } from "@opencode/core/session/runner/model"
import { SessionTable } from "@opencode/core/session/sql"
@@ -85,6 +86,7 @@ const it = testEffect(
SessionStore.node,
SessionCompaction.node,
SessionModelRequest.node,
PluginHooks.node,
]),
[Bus.node.replace(Bus.configured({ persist: true })), llmClient.replace(client)],
),
@@ -356,6 +358,14 @@ 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({
@@ -408,6 +418,8 @@ 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([
@@ -440,6 +452,65 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
}),
)
it.effect("compaction hooks can supply the summary instead of the model", () =>
Effect.gen(function* () {
requests = []
const db = (yield* Database.Service).db
const compaction = yield* SessionCompaction.Service
const hooks = yield* PluginHooks.Service
const store = yield* SessionStore.Service
const sessionID = Session.ID.make("ses_hooked_compaction")
const session = yield* insertSession(sessionID)
const modelRequests = yield* SessionModelRequest.Service
const messages = [
{
id: SessionMessage.ID.create(),
type: "user" as const,
text: "Hooked compaction should see this conversation.",
time: { created: DateTime.makeUnsafe(0) },
},
]
let contexts = 0
yield* hooks.register("session", "context", () => Effect.sync(() => contexts++))
yield* hooks.register("session", "compaction", (event) =>
Effect.sync(() => {
expect(event.sessionID).toBe(sessionID)
expect(event.agent).toBe(Agent.defaultID)
expect(JSON.stringify(event.messages)).toContain("Hooked compaction should see this conversation.")
event.result = { summary: "## Objective\n- hooked summary" }
}),
)
expect(
yield* compaction.compactManual({
session,
resolveContext: () => Effect.succeed(loaded(session, messages)),
prepare: modelRequests.compaction,
messages,
inputID: SessionMessage.ID.make("msg_hooked_compaction"),
}),
).toEqual({ status: "completed" })
expect(contexts).toBe(0)
expect(requests).toEqual([])
expect(yield* store.context(sessionID)).toMatchObject([
{ type: "compaction", reason: "manual", summary: "## Objective\n- hooked summary", recent: "" },
])
expect(
yield* db
.select({ type: EventTable.type })
.from(EventTable)
.where(eq(EventTable.aggregate_id, sessionID))
.orderBy(asc(EventTable.seq))
.all()
.pipe(Effect.orDie),
).toEqual([
{ type: Bus.versionedType(SessionEvent.Compaction.Started.type, 1) },
{ type: Bus.versionedType(SessionEvent.Compaction.Ended.type, 1) },
])
}),
)
it.effect("manual compaction records model resolution failures without calling the model", () =>
Effect.gen(function* () {
requests = []
+39 -2
View File
@@ -388,6 +388,32 @@ describe("Session.create", () => {
}),
)
it.effect("stores permission rules, inherits them through children and forks, and replaces them", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const permissions = [{ action: "edit", resource: "/original/**", effect: "deny" as const }]
const created = yield* session.create({ location, permissions })
expect(created.permissions).toEqual(permissions)
expect((yield* session.create({ parentID: created.id })).permissions).toEqual(permissions)
expect((yield* session.create({ parentID: created.id, permissions: [] })).permissions).toEqual([])
yield* session.prompt({ sessionID: created.id, text: "Fork context", resume: false })
yield* SessionInbox.promote(db, bus, created.id, "steer")
const forked = yield* session.fork({ sessionID: created.id, boundary: { type: "through" } })
expect(forked.permissions).toEqual(permissions)
const replaced = [{ action: "shell", resource: "*", effect: "ask" as const }]
yield* session.setPermissions({ sessionID: created.id, permissions: replaced })
expect((yield* session.get(created.id)).permissions).toEqual(replaced)
expect(
yield* session.setPermissions({ sessionID: Session.ID.create(), permissions: replaced }).pipe(Effect.flip),
).toBeInstanceOf(Session.NotFoundError)
}),
)
it.effect("inherits location from an existing parent when omitted", () =>
Effect.gen(function* () {
const session = yield* Session.Service
@@ -1330,7 +1356,12 @@ describe("SessionTransfer", () => {
const transfer = yield* SessionTransfer.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const template = yield* session.create({ location, title: "Exported", metadata: { channel: "C123" } })
const template = yield* session.create({
location,
title: "Exported",
metadata: { channel: "C123" },
permissions: [{ action: "edit", resource: "*", effect: "deny" }],
})
const sessionID = Session.ID.create()
const sourceMessageID = SessionMessage.ID.create()
const errorMessageID = SessionMessage.ID.create()
@@ -1376,7 +1407,13 @@ describe("SessionTransfer", () => {
})
const messages = yield* session.messages({ sessionID, order: "asc" })
expect(imported).toMatchObject({ id: sessionID, title: "Exported", location, metadata: { channel: "C123" } })
expect(imported).toMatchObject({
id: sessionID,
title: "Exported",
location,
metadata: { channel: "C123" },
permissions: [{ action: "edit", resource: "*", effect: "deny" }],
})
expect(imported.time).toMatchObject({
updated: DateTime.makeUnsafe(1_000),
idle: DateTime.makeUnsafe(200),
+49 -1
View File
@@ -1,5 +1,14 @@
import { expect } from "bun:test"
import { LLMClient, LLMEvent, LLMResponse, LanguageModel, ToolDefinition, type LLMRequest } from "@opencode/ai"
import {
LLMClient,
LLMEvent,
LLMResponse,
LanguageModel,
Message,
SystemPart,
ToolDefinition,
type LLMRequest,
} from "@opencode/ai"
import { OpenAIChat } from "@opencode/ai/protocols"
import type { StreamOptions } from "@opencode/ai/route"
import { Agent } from "@opencode/core/agent"
@@ -38,6 +47,7 @@ import {
import { SessionStore } from "@opencode/core/session/store"
import { SkillInstructions } from "@opencode/core/skill/instructions"
import { Plugin } from "@opencode/core/plugin"
import { PluginHooks } from "@opencode/core/plugin/hooks"
import { PluginSupervisor } from "@opencode/core/plugin/supervisor"
import { Tool } from "@opencode/core/tool"
import { asc, eq } from "drizzle-orm"
@@ -134,6 +144,7 @@ const it = testEffect(
Agent.node,
InstructionBuiltIns.node,
SessionContext.node,
PluginHooks.node,
llmClient,
]),
[
@@ -344,6 +355,43 @@ it.effect(
{ timeout: 15_000 },
)
it.effect(
"runs generate hooks instead of context hooks",
() =>
Effect.gen(function* () {
requests.length = 0
instruction = "Initial context"
const { db, bus, instructions, session, instances } = yield* setup
yield* InstructionState.prepare(db, bus, instructions, sessionID)
const hooks = yield* PluginHooks.Service
let contexts = 0
yield* hooks.register("session", "context", () => Effect.sync(() => contexts++))
yield* hooks.register("session", "generate", (event) =>
Effect.sync(() => {
expect(event.sessionID).toBe(sessionID)
expect(event.agent).toBe(Agent.ID.make("build"))
expect(Object.keys(event.tools)).toEqual(["lookup"])
event.system.push(SystemPart.make("Answer briefly."))
event.messages = [Message.user("[redacted]")]
event.options.maxTokens = 32
event.options.reasoningEffort = "low"
}),
)
yield* SessionGenerate.generate({ session, prompt: "Summarize privately" }).pipe(
Effect.provideService(Instance.Service, instances),
)
expect(contexts).toBe(0)
expect(requests).toHaveLength(1)
expect(requests[0]?.system.map((part) => part.text)).toContain("Answer briefly.")
expect(userTexts(requests[0])).toEqual(["[redacted]"])
expect(requests[0]?.generation).toEqual(expect.objectContaining({ maxTokens: 32 }))
expect(requests[0]?.providerOptions).toEqual({ reasoningEffort: "low" })
}),
{ timeout: 15_000 },
)
it.effect(
"blocks unavailable initial instructions before generation",
() =>
@@ -400,6 +400,34 @@ it.live("only known automatic native overflow falls back locally and failed reco
}),
)
it.live("compaction hooks supply the summary instead of provider compaction", () =>
Effect.gen(function* () {
const fixture = yield* setup()
yield* fixture.prompt("Original user")
yield* fixture.hooks.register("session", "compaction", (event) =>
Effect.sync(() => {
event.result = {
summary: "## Objective\n- hooked summary",
providerState: { responseId: "plugin" },
metadata: { plugin: "custom" },
tokens: { input: 10, output: 5, reasoning: 0, cache: { read: 0, write: 0 } },
}
}),
)
expect(yield* fixture.compact).toEqual({ status: "completed" })
expect(fixture.state.calls).toBe(0)
expect((yield* fixture.load).messages.at(-1)).toMatchObject({
type: "compaction",
status: "completed",
summary: "## Objective\n- hooked summary",
recent: "",
providerState: { responseId: "plugin" },
metadata: { plugin: "custom" },
tokens: { input: 10, output: 5 },
})
}),
)
it.live("rejects request-hook route rewrites before provider compaction", () =>
Effect.gen(function* () {
const fixture = yield* setup()
+15 -4
View File
@@ -20,6 +20,7 @@ import { OpenAIChat } from "@opencode/ai/protocols/openai-chat"
import { AnthropicMessages, OpenAIResponses } from "@opencode/ai/protocols"
import { compileRequest } from "@opencode/ai/route/client"
import { TestLLM } from "@opencode/ai/testing"
import type { SessionHooks } from "@opencode/plugin/effect/session"
import { Catalog } from "@opencode/core/catalog"
import { Database } from "@opencode/core/database/database"
import { makeLocationNode } from "@opencode/util/effect/app-node"
@@ -1943,7 +1944,16 @@ describe("SessionRunnerLLM", () => {
expect(yield* entries.list(sessionID)).toEqual([{ key: "nullable", value: null }])
})
scenario("rejects API instruction entries larger than 8KB", function* () {
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* () {
const entries = yield* InstructionEntry.Service
const exit = yield* entries
@@ -2411,15 +2421,16 @@ describe("SessionRunnerLLM", () => {
model: { id: ID.make(s.currentModel.id), providerID: Provider.ID.make(s.currentModel.provider), variant },
})
const requestAgents: Agent.ID[] = []
yield* hooks.register("session", "context", (event) =>
const hook = (event: SessionHooks["context"]) =>
Effect.sync(() => {
expect(event.agent).toBe(agentID)
expect(event.model.variant).toBe(variant)
event.system.push(SystemPart.make("Hook-provided instructions"))
event.tools.echo.description = "Hook-provided tool description"
event.options.maxTokens = 4_000
}),
)
})
yield* hooks.register("session", "context", hook)
yield* hooks.register("session", "compaction", hook)
yield* hooks.register("session", "model.request", (event) =>
Effect.sync(() => {
requestAgents.push(event.agent)
@@ -1280,6 +1280,91 @@ flowchart TD
])
})
test("expands & node groups into fan-in and fan-out edges", () => {
const diagram = parseMermaidFlowchartDiagram(`flowchart LR
N[Native] & M[Mapped] & O --> LM["LanguageModel"]
LM -->|prepare| REQ & LOG`)
expect(diagram.nodes).toEqual([
{ id: "N", label: "Native", shape: "box" },
{ id: "M", label: "Mapped", shape: "box" },
{ id: "O", label: "O", shape: "box" },
{ id: "LM", label: "LanguageModel", shape: "box" },
{ id: "REQ", label: "REQ", shape: "box" },
{ id: "LOG", label: "LOG", shape: "box" },
])
expect(diagram.edges).toEqual([
{ from: "N", to: "LM", label: "" },
{ from: "M", to: "LM", label: "" },
{ from: "O", to: "LM", label: "" },
{ from: "LM", to: "REQ", label: "prepare" },
{ from: "LM", to: "LOG", label: "prepare" },
])
})
test("expands & groups on both sides of an edge and through a chain", () => {
const diagram = parseMermaidFlowchartDiagram(`flowchart LR
A & B --> C & D --> E`)
expect(diagram.edges).toEqual([
{ from: "A", to: "C", label: "" },
{ from: "A", to: "D", label: "" },
{ from: "B", to: "C", label: "" },
{ from: "B", to: "D", label: "" },
{ from: "C", to: "E", label: "" },
{ from: "D", to: "E", label: "" },
])
})
test("declares every node of a bare & group inside the current subgraph", () => {
const diagram = parseMermaidFlowchartDiagram(`flowchart TD
subgraph Runtime
A[Alpha] & B[Beta]:::focus
end
A --> B`)
expect(diagram.nodes).toEqual([
{ id: "A", label: "Alpha", shape: "box" },
{ id: "B", label: "Beta", shape: "box" },
])
expect(diagram.subgraphs?.[0]?.nodeIds).toEqual(["A", "B"])
})
test("keeps & inside quoted or bracketed labels as label text", () => {
const diagram = parseMermaidFlowchartDiagram(`flowchart LR
A["Fetch & parse"] & B[R&D] --> C[Done &amp; dusted]`)
expect(diagram.nodes).toEqual([
{ id: "A", label: "Fetch & parse", shape: "box" },
{ id: "B", label: "R&D", shape: "box" },
{ id: "C", label: "Done & dusted", shape: "box" },
])
expect(diagram.edges).toEqual([
{ from: "A", to: "C", label: "" },
{ from: "B", to: "C", label: "" },
])
})
test("rejects an empty & group member", () => {
expect(() =>
parseMermaidFlowchartDiagram(`flowchart LR
A & --> B`),
).toThrow('Unsupported syntax in flowchart diagram at line 2: "A & --> B"')
})
test("renders a fan-in expressed with & the same as separate edge statements", () => {
const grouped = renderFlowchartDiagram(`flowchart LR
N & M & O --> LM[LanguageModel] --> REQ[LLMRequest]`)
const separate = renderFlowchartDiagram(`flowchart LR
N --> LM[LanguageModel]
M --> LM
O --> LM
LM --> REQ[LLMRequest]`)
expect(grouped).toBe(separate)
expect(grouped).toContain("LanguageModel")
})
test("parses chained undirected solid edges", () => {
const diagram = parseMermaidFlowchartDiagram(`flowchart LR
A --- B --- C`)
+66 -25
View File
@@ -127,6 +127,41 @@ function stripNodeToken(token: string): string {
.trim()
}
/** Split an `&`-joined node group, leaving `&` inside labels (brackets or quotes) untouched. */
function splitNodeGroup(token: string): string[] {
const groups: string[] = []
const stack: string[] = []
let quote: '"' | "'" | undefined
let start = 0
const closes: Record<string, string> = { "[": "]", "(": ")", "{": "}" }
for (let index = 0; index < token.length; index++) {
const character = token[index]!
if (quote) {
if (character === quote && token[index - 1] !== "\\") quote = undefined
continue
}
if (character === '"' || character === "'") {
quote = character
continue
}
if (character in closes) {
stack.push(character)
continue
}
if (stack.length > 0 && character === closes[stack.at(-1)!]) {
stack.pop()
continue
}
if (stack.length === 0 && character === "&") {
groups.push(token.slice(start, index))
start = index + 1
}
}
groups.push(token.slice(start))
return groups
}
function edgeStyleFromArrow(...arrows: string[]): FlowchartEdgeStyle | undefined {
if (arrows.some((arrow) => arrow.includes("=="))) return "thick"
if (arrows.some((arrow) => arrow.includes("."))) return "dashed"
@@ -305,51 +340,57 @@ export function parseMermaidFlowchartDiagram(content: string): FlowchartDiagram
const edgeOperators = parseEdgeOperators(line)
if (edgeOperators.length > 0) {
const nodeTokens = [
// Each chain position may be an `&` group (`A & B --> C`), so endpoints are lists of node tokens.
const nodeGroups = [
line.slice(0, edgeOperators[0]!.index),
...edgeOperators.map((operator, index) =>
line.slice(operator.end, edgeOperators[index + 1]?.index ?? line.length),
),
]
].map((group) => splitNodeGroup(group).map(stripNodeToken))
if (nodeTokens.every((token) => stripNodeToken(token).length > 0)) {
const unsupportedEndpoint = nodeTokens.find((token, index) => {
const stripped = stripNodeToken(token)
if (nodeGroups.every((group) => group.every((token) => token.length > 0))) {
const unsupportedEndpoint = nodeGroups.find((group, index) => {
const orderOnlyEndpoint = edgeOperators[index - 1]?.orderOnly || edgeOperators[index]?.orderOnly
return (
!(orderOnlyEndpoint && subgraphs.some((subgraph) => subgraph.id === stripped)) &&
!isSupportedNodeToken(stripped)
return group.some(
(stripped) =>
!(orderOnlyEndpoint && subgraphs.some((subgraph) => subgraph.id === stripped)) &&
!isSupportedNodeToken(stripped),
)
})
if (unsupportedEndpoint) throw new MermaidSyntaxError("flowchart", source.lineNumber, line)
const chainNodeIds = nodeTokens.map((token, index) => {
const stripped = stripNodeToken(token)
const chainNodeIds = nodeGroups.map((group, index) => {
const orderOnlyEndpoint = edgeOperators[index - 1]?.orderOnly || edgeOperators[index]?.orderOnly
if (orderOnlyEndpoint && subgraphs.some((subgraph) => subgraph.id === stripped)) return stripped
return ensureNode(nodes, stripped).id
return group.map((stripped) => {
if (orderOnlyEndpoint && subgraphs.some((subgraph) => subgraph.id === stripped)) return stripped
return ensureNode(nodes, stripped).id
})
})
for (const nodeId of chainNodeIds) {
for (const nodeId of chainNodeIds.flat()) {
if (nodes.has(nodeId)) addNodeToSubgraph(currentSubgraph, nodeId)
}
for (let index = 0; index < edgeOperators.length; index++) {
const operator = edgeOperators[index]!
const edge = createEdge(
chainNodeIds[index]!,
chainNodeIds[index + 1]!,
operator.label,
operator.style,
operator.arrowhead,
operator.sourceArrowhead,
)
edges.push(operator.orderOnly ? { ...edge, orderOnly: true } : edge)
for (const from of chainNodeIds[index]!) {
for (const to of chainNodeIds[index + 1]!) {
const edge = createEdge(
from,
to,
operator.label,
operator.style,
operator.arrowhead,
operator.sourceArrowhead,
)
edges.push(operator.orderOnly ? { ...edge, orderOnly: true } : edge)
}
}
}
continue
}
}
if (isSupportedNodeToken(line)) {
const node = ensureNode(nodes, line)
addNodeToSubgraph(currentSubgraph, node.id)
const nodeGroup = splitNodeGroup(line)
if (nodeGroup.every(isSupportedNodeToken)) {
for (const token of nodeGroup) addNodeToSubgraph(currentSubgraph, ensureNode(nodes, stripNodeToken(token)).id)
continue
}
+1 -1
View File
@@ -29,7 +29,7 @@ describe("parser diagnostics", () => {
})
test("does not partially parse unsupported flowchart syntax", () => {
for (const statement of ["A & B --> C", "A((Start)) --> B", "A-->B; B-->C"]) {
for (const statement of ["A & --> C", "A((Start)) --> B", "A-->B; B-->C"]) {
expect(() => parseMermaidFlowchartDiagram(`flowchart LR\n ${statement}`)).toThrow(MermaidSyntaxError)
}
})
+1 -1
View File
@@ -19,6 +19,6 @@ export interface PermissionHooks {
readonly evaluate: PermissionEvaluation
}
export type PermissionDomain = Pick<PermissionApi<unknown>, "list" | "get" | "reply"> & {
export type PermissionDomain = Pick<PermissionApi<unknown>, "list" | "get" | "reply" | "rules"> & {
readonly hook: Hooks<PermissionHooks>
}
+17
View File
@@ -7,6 +7,7 @@ import type { Session } from "@opencode/schema/session"
import type { SessionInbox } from "@opencode/schema/session-inbox"
import type { SessionError } from "@opencode/schema/session-error"
import type { SessionMessage } from "@opencode/schema/session-message"
import type { TokenUsage } from "@opencode/schema/token-usage"
import type { JsonSchema, Types } from "effect"
import type { ModelHooks } from "./registration.js"
@@ -34,6 +35,20 @@ export interface SessionContext extends SessionRequest {
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
}
export interface SessionCompactionResult {
summary: string
providerState?: SessionMessage.ProviderState
metadata?: Record<string, unknown>
tokens?: TokenUsage.Info
}
export interface SessionCompaction extends SessionContext {
/** Set to use this compaction and skip the model request. */
result?: SessionCompactionResult
}
export interface SessionGenerate extends SessionContext {}
export interface SessionTitle extends SessionRequest {
/** Set to use this title and skip the model request. */
result?: string
@@ -85,6 +100,8 @@ export interface SessionRetry {
export interface SessionHooks {
readonly prompt: SessionPrompt
readonly context: SessionContext
readonly compaction: SessionCompaction
readonly generate: SessionGenerate
readonly title: SessionTitle
readonly "model.request": SessionModelRequest
readonly "http.request": SessionHttpRequest
+1
View File
@@ -438,6 +438,7 @@ export function fromPromise(plugin: Plugin) {
list: adaptApiMethod(PermissionEndpoints["session.permission.list"], host.permission.list),
get: adaptApiMethod(PermissionEndpoints["session.permission.get"], host.permission.get),
reply: adaptApiMethod(PermissionEndpoints["session.permission.reply"], host.permission.reply),
rules: adaptApiMethod(PermissionEndpoints["session.permission.rules"], host.permission.rules),
},
plugin: {
list: adaptApiMethod(PluginEndpoints["plugin.list"], host.plugin.list),
+1 -1
View File
@@ -19,6 +19,6 @@ export interface PermissionHooks {
readonly evaluate: PermissionEvaluation
}
export type PermissionDomain = Pick<PermissionApi, "list" | "get" | "reply"> & {
export type PermissionDomain = Pick<PermissionApi, "list" | "get" | "reply" | "rules"> & {
readonly hook: Hooks<PermissionHooks>
}
+17
View File
@@ -7,6 +7,7 @@ import type { Session } from "@opencode/schema/session"
import type { SessionInbox } from "@opencode/schema/session-inbox"
import type { SessionError } from "@opencode/schema/session-error"
import type { SessionMessage } from "@opencode/schema/session-message"
import type { TokenUsage } from "@opencode/schema/token-usage"
import type { JsonSchema, Types } from "effect"
import type { ModelHooks } from "./registration.js"
@@ -34,6 +35,20 @@ export interface SessionContext extends SessionRequest {
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
}
export interface SessionCompactionResult {
summary: string
providerState?: SessionMessage.ProviderState
metadata?: Record<string, unknown>
tokens?: TokenUsage.Info
}
export interface SessionCompaction extends SessionContext {
/** Set to use this compaction and skip the model request. */
result?: SessionCompactionResult
}
export interface SessionGenerate extends SessionContext {}
export interface SessionTitle extends SessionRequest {
/** Set to use this title and skip the model request. */
result?: string
@@ -85,6 +100,8 @@ export interface SessionRetry {
export interface SessionHooks {
readonly prompt: SessionPrompt
readonly context: SessionContext
readonly compaction: SessionCompaction
readonly generate: SessionGenerate
readonly title: SessionTitle
readonly "model.request": SessionModelRequest
readonly "http.request": SessionHttpRequest
@@ -132,4 +132,21 @@ export const makePermissionGroup = <
}),
),
)
.add(
HttpApiEndpoint.put("session.permission.rules", "/api/session/:sessionID/permission/rules", {
params: { sessionID: Session.ID },
payload: Schema.Struct({ permissions: Permission.Ruleset }),
success: HttpApiSchema.NoContent,
error: SessionNotFoundError,
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.permission.rules",
summary: "Replace session permission rules",
description:
"Replace the session-scoped permission rules. Rules are evaluated after the agent's rules, and the last matching rule wins.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "permission", description: "Experimental permission routes." }))
+2
View File
@@ -27,6 +27,7 @@ import {
import { Agent } from "@opencode/schema/agent"
import { Skill } from "@opencode/schema/skill"
import { Model } from "@opencode/schema/model"
import { Permission } from "@opencode/schema/permission"
import { Location } from "@opencode/schema/location"
import { SessionEvent } from "@opencode/schema/session-event"
import { EventLog } from "@opencode/schema/event-log"
@@ -175,6 +176,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
model: Model.Ref.pipe(Schema.optional),
location: Location.Ref.pipe(Schema.optional),
metadata: Session.Metadata.pipe(Schema.optional),
permissions: Permission.Ruleset.pipe(Schema.optional),
}),
success: Schema.Struct({ data: Session.Info }),
}).annotateMerge(
+1 -1
View File
@@ -27,7 +27,7 @@ export const Snapshot = Schema.Array(
).annotate({ identifier: "InstructionEntry.Snapshot" })
export type Snapshot = typeof Snapshot.Type
export const MaxValueBytes = 8 * 1024
export const MaxValueBytes = 256 * 1024
export class ValueTooLargeError extends Schema.TaggedError<ValueTooLargeError>()(
"InstructionEntryValueTooLargeError",
+13
View File
@@ -25,6 +25,7 @@ import { TokenUsage } from "./token-usage.js"
import { SessionInbox } from "./session-inbox.js"
import { Project } from "./project.js"
import { SessionFork } from "./session-fork.js"
import { Permission } from "./permission.js"
export { FileAttachment }
@@ -62,6 +63,7 @@ export const Created = Event.durable({
model: Model.Ref.pipe(optional),
/** Host-supplied annotations resolved at creation, including any inherited from a parent. */
metadata: SessionMetadata.pipe(optional),
permissions: Permission.Ruleset.pipe(optional),
version: Schema.String,
},
})
@@ -109,6 +111,16 @@ export const Renamed = Event.durable({
})
export type Renamed = typeof Renamed.Type
export const PermissionsUpdated = Event.durable({
type: "session.permissions.updated",
...options,
schema: {
...Base,
permissions: Permission.Ruleset,
},
})
export type PermissionsUpdated = typeof PermissionsUpdated.Type
export const Viewed = Event.durable({
type: "session.viewed",
...options,
@@ -634,6 +646,7 @@ export const Definitions = Event.inventory(
ModelSelected,
Moved,
Renamed,
PermissionsUpdated,
Viewed,
UsageUpdated,
Deleted,
+3
View File
@@ -10,6 +10,7 @@ import { SessionEvent } from "./session-event.js"
import { SessionID } from "./session-id.js"
import { SessionMetadata } from "./session-metadata.js"
import { Money } from "./money.js"
import { Permission } from "./permission.js"
import { TokenUsage } from "./token-usage.js"
import { Revert } from "./session-revert.js"
import { SessionFork } from "./session-fork.js"
@@ -54,6 +55,8 @@ export const Info = Schema.Struct({
location: Location.Ref,
subpath: RelativePath.pipe(optional),
metadata: Metadata.pipe(optional),
/** Evaluated after the agent's rules; the last matching rule wins. */
permissions: Permission.Ruleset.pipe(optional),
revert: Revert.pipe(optional),
}).annotate({ identifier: "Session.Info" })
@@ -115,6 +115,7 @@ describe("public event manifest", () => {
"session.model.selected.1",
"session.moved.1",
"session.renamed.1",
"session.permissions.updated.1",
"session.viewed.1",
"session.message.content.updated.1",
"session.usage.recorded.1",
@@ -83,6 +83,15 @@ export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission",
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"session.permission.rules",
Effect.fn(function* (ctx) {
yield* sessions
.setPermissions({ sessionID: ctx.params.sessionID, permissions: ctx.payload.permissions })
.pipe(Effect.catchTag("Session.NotFoundError", missingSession))
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"permission.saved.list",
Effect.fn(function* (ctx) {
+1
View File
@@ -120,6 +120,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
agent: ctx.payload.agent,
model: ctx.payload.model,
metadata: ctx.payload.metadata,
permissions: ctx.payload.permissions,
location: ctx.payload.location ?? { directory: AbsolutePath.make(process.cwd()) },
})
.pipe(Effect.orDie),
@@ -13,6 +13,7 @@ import { Plugin } from "@opencode/core/plugin"
import { Session } from "@opencode/core/session"
import { SessionRunnerModel } from "@opencode/core/session/runner/model"
import { define } from "@opencode/plugin/effect/plugin"
import type { SessionHooks } from "@opencode/plugin/effect/session"
import { Agent } from "@opencode/schema/agent"
import { Location } from "@opencode/schema/location"
import { AbsolutePath } from "@opencode/schema/schema"
@@ -92,11 +93,12 @@ it.live(
event.prompt.text += ` [${config.tool}]`
}),
)
yield* ctx.session.hook("context", (event) =>
const tune = (event: SessionHooks["context"]) =>
Effect.sync(() => {
event.options.temperature = config.temperature
}),
)
})
yield* ctx.session.hook("context", tune)
yield* ctx.session.hook("generate", tune)
yield* ctx.permission.hook("evaluate", (event) =>
Effect.sync(() => {
event.effect = event.action === "instance-test" ? "ask" : "allow"
@@ -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={props.message.status === "running"}>
<div role="status" class="py-2">
<BasicTool
icon="archive"
trigger={{ title: i18n.t("ui.messagePart.compaction.running") }}
status="running"
locked
hideDetails
/>
</div>
</Show>
<Show when={summary().trim()}>
<div data-component="text-part" data-timeline-part-id={props.message.id}>
<div data-slot="text-part-body">
@@ -449,6 +438,17 @@ export function SessionCompactionMessage(props: { message: SessionMessageCompact
</div>
</div>
</Show>
<Show when={props.message.status === "running"}>
<div role="status" class="py-2">
<BasicTool
icon="archive"
trigger={{ title: i18n.t("ui.messagePart.compaction.running") }}
status="running"
locked
hideDetails
/>
</div>
</Show>
<Show when={props.message.status !== "running"}>
<div class="py-2">
<TimelineSeparator label={label()} />
@@ -39,6 +39,7 @@ describe("inference stat normalization", () => {
})
test("merges renamed models under their current name", () => {
expect(statModel("deepseek-flash", "")).toBe("deepseek-v4.1-flash")
expect(statModel("x-preview-f", "")).toBe("ox-alpha")
expect(statModel("xiaomi/mimo-v2.5", "")).toBe("mimo-v2.5")
expect(toModelAggregate(aggregate("x-preview-f", "openai"))).toMatchObject([
@@ -14,6 +14,7 @@ export const MODEL_AUTHOR_RULES = [
] as const
export const EXCLUDED_MODELS = new Set(["alpha-gpt-next"])
export const MODEL_NAME_ALIASES: Record<string, string> = {
"deepseek-flash": "deepseek-v4.1-flash",
"x-preview-f": "ox-alpha",
"xiaomi/mimo-v2.5": "mimo-v2.5",
}
+2 -1
View File
@@ -51,7 +51,8 @@ export function createClipboardAdapter(clipboard: CoreClipboardService): OwnedCl
throw new Error(`Unexpected clipboard MIME type: ${result.representation.mimeType}`)
},
async write(text) {
const result = await clipboard.writeText(text, {
// OpenTUI rejects NUL before any destination; host clipboard text cannot contain it.
const result = await clipboard.writeText(text.replaceAll("\0", ""), {
destination: "all-available",
selection: "clipboard",
})
+1
View File
@@ -274,6 +274,7 @@ export const Definitions = {
"permission.prompt.fullscreen": keybind("ctrl+f", "Toggle permission prompt fullscreen"),
"plugins.toggle": keybind("return", "Toggle plugin"),
"dialog.mcp.toggle": keybind("space", "Toggle MCP server"),
"dialog.plugins.error": keybind("space", "View plugin error"),
"dialog.plugins.install": keybind("shift+i", "Install plugin from plugin dialog"),
"dialog.plugins.update": keybind("ctrl+u", "Update plugin from plugin dialog"),
"dialog.plugins.check": keybind("ctrl+r", "Check for plugin updates from plugin dialog"),
+6 -1
View File
@@ -169,7 +169,12 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
: members.some((id) => (data.session.form.list(id)?.length ?? 0) > 0)
? ("question" as const)
: (false as const),
busy: members.some((id) => data.session.status(id) === "running" || data.session.pending.list(id).length > 0),
// 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"),
),
renaming: data.session.title.pending(session),
}
}
@@ -223,6 +223,15 @@ export function PluginsDialog(props: {
disabled: checking(),
onTrigger: check,
},
{
title: "view error",
command: "dialog.plugins.error",
hidden: !pluginError(focusedTui()),
onTrigger: (option) => {
const entry = entries().find((entry) => entry.key === option.value)
if (pluginError(entry)) setDetail(entry)
},
},
{
title: toggleTitle(),
command: "plugins.toggle",
@@ -239,7 +248,7 @@ export function PluginsDialog(props: {
},
]}
footer={
<Show when={pluginError(focusedEntry())}>
<Show when={pluginError(focusedEntry()) && !focusedTui()}>
<text>
<span style={{ fg: props.context.theme.text.default }}>
<b>enter</b>
@@ -0,0 +1,111 @@
export type GroupNode<Entry, Kind extends string> =
| { readonly type: "entry"; readonly entry: Entry; readonly size: 1 }
| {
readonly type: "group"
readonly kind: Kind
readonly children: readonly GroupNode<Entry, Kind>[]
/** Number of descendant leaves, independent of disclosure state. */
readonly size: number
}
/**
* Group adjacent entries by their configured nesting paths. For example, a read
* can use ["exploration"] today or ["activity", "exploration"] in Low.
* Entries are opaque: message/part identity, visibility and live state remain
* owned by the session projection. A path of [] creates a standalone leaf.
*/
export function groupEntries<Entry, Kind extends string>(
entries: readonly Entry[],
path: (entry: Entry) => readonly Kind[],
): readonly GroupNode<Entry, Kind>[] {
const result: BuildingNode<Entry, Kind>[] = []
entries.forEach((entry) => {
appendEntry(result, entry, path(entry))
})
return result
}
// Only freshly constructed nodes are writable; the published tree is readonly.
type BuildingNode<Entry, Kind extends string> =
| { type: "entry"; entry: Entry; size: 1 }
| { type: "group"; kind: Kind; children: BuildingNode<Entry, Kind>[]; size: number }
function appendEntry<Entry, Kind extends string>(
nodes: BuildingNode<Entry, Kind>[],
entry: Entry,
path: readonly Kind[],
depth = 0,
) {
const kind = path[depth]
if (kind === undefined) {
nodes.push({ type: "entry", entry, size: 1 })
return
}
const previous = nodes.at(-1)
if (previous?.type === "group" && previous.kind === kind) {
previous.size++
appendEntry(previous.children, entry, path, depth + 1)
return
}
const children: BuildingNode<Entry, Kind>[] = []
appendEntry(children, entry, path, depth + 1)
nodes.push({ type: "group", kind, children, size: 1 })
}
/**
* Concatenate ordered, disjoint chunks, recursively merging compatible groups
* at their seam. Untouched subtrees retain their object identity.
*
* This is concatenation, not ingestion: callers must reconcile overlapping
* pages/replayed message IDs before merging. Equal payloads may be distinct
* entries and must not be silently deduplicated here.
*/
export function mergeGroups<Entry, Kind extends string>(
left: readonly GroupNode<Entry, Kind>[],
right: readonly GroupNode<Entry, Kind>[],
): readonly GroupNode<Entry, Kind>[] {
if (!left.length) return right
if (!right.length) return left
const a = left[left.length - 1]
const b = right[0]
if (a.type !== "group" || b.type !== "group" || a.kind !== b.kind) return [...left, ...right]
return [
...left.slice(0, -1),
{
type: "group",
kind: a.kind,
size: a.size + b.size,
children: mergeGroups(a.children, b.children),
},
...right.slice(1),
]
}
/**
* Split at a depth-first leaf offset. Group headers count as zero. Cached sizes
* skip whole subtrees; only the ancestors crossing the cut are reconstructed.
* The returned halves can be seam-merged again without changing their meaning.
*/
export function splitGroups<Entry, Kind extends string>(
nodes: readonly GroupNode<Entry, Kind>[],
count: number,
): readonly [readonly GroupNode<Entry, Kind>[], readonly GroupNode<Entry, Kind>[]] {
if (!Number.isInteger(count) || count < 0) throw new RangeError("Group split requires a non-negative integer")
if (count === 0) return [[], nodes]
let offset = 0
for (const [index, node] of nodes.entries()) {
const end = offset + node.size
if (count === end) return [nodes.slice(0, index + 1), nodes.slice(index + 1)]
if (count < end) {
if (node.type !== "group") throw new RangeError("Cannot split inside an entry")
const size = count - offset
const [left, right] = splitGroups(node.children, size)
return [
[...nodes.slice(0, index), { ...node, children: left, size }],
[{ ...node, children: right, size: node.size - size }, ...nodes.slice(index + 1)],
]
}
offset = end
}
throw new RangeError("Group split exceeds entry count")
}

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