mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-10 19:06:24 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2af928895 |
@@ -181,7 +181,7 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
|
||||
responseID,
|
||||
request,
|
||||
// Completion can re-encrypt reasoning. Callers replay the item already emitted by output_item.done.
|
||||
output: event.response?.output?.length
|
||||
output: event.response?.output
|
||||
? event.response.output.map((item) =>
|
||||
item.type === "reasoning" && item.id !== undefined
|
||||
? (output.find((done) => done.type === item.type && done.id === item.id) ?? item)
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { OpenResponses } from "../protocols/open-responses.js"
|
||||
import { OpenAIResponses } from "../protocols/openai-responses.js"
|
||||
import { BedrockAuth, type Credentials } from "../protocols/utils/bedrock-auth.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
@@ -38,10 +37,11 @@ const responsesRoute = Route.make({
|
||||
id: "bedrock-mantle-responses",
|
||||
provider: id,
|
||||
providerMetadataKey: "mantle",
|
||||
protocol: OpenResponses.protocol,
|
||||
endpoint: Endpoint.path(OpenResponses.PATH),
|
||||
transport: OpenResponses.httpTransport,
|
||||
defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } },
|
||||
protocol: OpenAIResponses.protocol,
|
||||
endpoint: OpenAIResponses.route.endpoint,
|
||||
auth: OpenAIResponses.route.auth,
|
||||
transport: OpenAIResponses.httpTransport,
|
||||
defaults: OpenAIResponses.route.defaults,
|
||||
})
|
||||
|
||||
const chatRoute = OpenAIChat.route.with({
|
||||
|
||||
@@ -4,7 +4,7 @@ import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, Message } from "../../src/index.js"
|
||||
import { AmazonBedrockMantle } from "../../src/providers.js"
|
||||
import { model } from "../../src/providers/amazon-bedrock/mantle.js"
|
||||
import { OpenResponses } from "../../src/protocols/open-responses.js"
|
||||
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
|
||||
import { compileRequest, LLMClient } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { withProcessEnv } from "../lib/env.js"
|
||||
@@ -25,7 +25,7 @@ describe("Amazon Bedrock Mantle provider", () => {
|
||||
expect(provider.model).toBe(provider.responses)
|
||||
expect(AmazonBedrockMantle.model).toBe(AmazonBedrockMantle.responsesModel)
|
||||
expect(model).toBe(AmazonBedrockMantle.responsesModel)
|
||||
expect(provider.model("openai.gpt-oss-120b").route.transport).toBe(OpenResponses.httpTransport)
|
||||
expect(provider.model("openai.gpt-oss-120b").route.transport).toBe(OpenAIResponses.httpTransport)
|
||||
const chat = yield* compileRequest(LLM.request({ model: provider.chat("openai.gpt-oss-120b"), prompt: "Hi" }))
|
||||
const responses = yield* compileRequest(
|
||||
LLM.request({ model: provider.model("openai.gpt-oss-120b"), prompt: "Hi" }),
|
||||
@@ -38,7 +38,7 @@ describe("Amazon Bedrock Mantle provider", () => {
|
||||
})
|
||||
expect(responses).toMatchObject({
|
||||
route: "bedrock-mantle-responses",
|
||||
protocol: "open-responses",
|
||||
protocol: "openai-responses",
|
||||
body: { model: "openai.gpt-oss-120b", store: false },
|
||||
})
|
||||
expect(provider.model("openai.gpt-oss-120b").route.providerMetadataKey).toBe("mantle")
|
||||
@@ -178,7 +178,7 @@ describe("Amazon Bedrock Mantle provider", () => {
|
||||
const recorded = recordedTests({
|
||||
prefix: "bedrock-mantle",
|
||||
provider: "amazon-bedrock",
|
||||
protocol: "open-responses",
|
||||
protocol: "openai-responses",
|
||||
requires: ["AWS_BEARER_TOKEN_BEDROCK"],
|
||||
metadata: { model: "openai.gpt-oss-120b" },
|
||||
})
|
||||
|
||||
@@ -581,54 +581,52 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
it.effect("continues a streamed tool call with only the new tool output", () =>
|
||||
Effect.forEach([undefined, []], (output) =>
|
||||
Effect.gen(function* () {
|
||||
const firstRequest = {
|
||||
type: "response.create",
|
||||
model: "gpt-5.2",
|
||||
store: false,
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "Weather?" }] }],
|
||||
}
|
||||
const first = continuationDriver(firstRequest)
|
||||
const firstCreate = yield* first.create(undefined)
|
||||
Effect.gen(function* () {
|
||||
const firstRequest = {
|
||||
type: "response.create",
|
||||
model: "gpt-5.2",
|
||||
store: false,
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "Weather?" }] }],
|
||||
}
|
||||
const first = continuationDriver(firstRequest)
|
||||
const firstCreate = yield* first.create(undefined)
|
||||
yield* first.observe(
|
||||
firstCreate,
|
||||
ProviderShared.encodeJson({
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: "fc_1",
|
||||
status: "completed",
|
||||
call_id: "call_1",
|
||||
name: "weather",
|
||||
arguments: '{ "city": "Paris" }',
|
||||
},
|
||||
}),
|
||||
)
|
||||
const saved = checkpoint(
|
||||
yield* first.observe(
|
||||
firstCreate,
|
||||
ProviderShared.encodeJson({
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: "fc_1",
|
||||
status: "completed",
|
||||
call_id: "call_1",
|
||||
name: "weather",
|
||||
arguments: '{ "city": "Paris" }',
|
||||
},
|
||||
}),
|
||||
)
|
||||
const saved = checkpoint(
|
||||
yield* first.observe(
|
||||
firstCreate,
|
||||
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1", output } }),
|
||||
),
|
||||
)
|
||||
const second = continuationDriver({
|
||||
...firstRequest,
|
||||
input: [
|
||||
...firstRequest.input,
|
||||
{ type: "function_call", call_id: "call_1", name: "weather", arguments: '{"city":"Paris"}' },
|
||||
{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' },
|
||||
],
|
||||
})
|
||||
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
|
||||
),
|
||||
)
|
||||
const second = continuationDriver({
|
||||
...firstRequest,
|
||||
input: [
|
||||
...firstRequest.input,
|
||||
{ type: "function_call", call_id: "call_1", name: "weather", arguments: '{"city":"Paris"}' },
|
||||
{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' },
|
||||
],
|
||||
})
|
||||
|
||||
const create = yield* second.create(saved)
|
||||
const create = yield* second.create(saved)
|
||||
|
||||
expect(create.mode).toBe("incremental")
|
||||
expect(ProviderShared.decodeJson(create.message)).toMatchObject({
|
||||
previous_response_id: "resp_1",
|
||||
input: [{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' }],
|
||||
})
|
||||
}),
|
||||
),
|
||||
expect(create.mode).toBe("incremental")
|
||||
expect(ProviderShared.decodeJson(create.message)).toMatchObject({
|
||||
previous_response_id: "resp_1",
|
||||
input: [{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues a tool call from authoritative completed response output", () =>
|
||||
@@ -682,47 +680,45 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
it.effect("continues a promoted steer after assistant output with response-only text metadata", () =>
|
||||
Effect.forEach([undefined, []], (output) =>
|
||||
Effect.gen(function* () {
|
||||
const firstInput = [{ role: "user", content: [{ type: "input_text", text: "First" }] }]
|
||||
const first = continuationDriver({ type: "response.create", model: "gpt-5.2", store: false, input: firstInput })
|
||||
const create = yield* first.create(undefined)
|
||||
Effect.gen(function* () {
|
||||
const firstInput = [{ role: "user", content: [{ type: "input_text", text: "First" }] }]
|
||||
const first = continuationDriver({ type: "response.create", model: "gpt-5.2", store: false, input: firstInput })
|
||||
const create = yield* first.create(undefined)
|
||||
yield* first.observe(
|
||||
create,
|
||||
ProviderShared.encodeJson({
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "message",
|
||||
id: "msg_1",
|
||||
status: "completed",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Hello", annotations: [], logprobs: [] }],
|
||||
},
|
||||
}),
|
||||
)
|
||||
const saved = checkpoint(
|
||||
yield* first.observe(
|
||||
create,
|
||||
ProviderShared.encodeJson({
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "message",
|
||||
id: "msg_1",
|
||||
status: "completed",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Hello", annotations: [], logprobs: [] }],
|
||||
},
|
||||
}),
|
||||
)
|
||||
const saved = checkpoint(
|
||||
yield* first.observe(
|
||||
create,
|
||||
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1", output } }),
|
||||
),
|
||||
)
|
||||
const steer = { role: "user", content: [{ type: "input_text", text: "Actually, be brief" }] }
|
||||
const next = continuationDriver({
|
||||
type: "response.create",
|
||||
model: "gpt-5.2",
|
||||
store: false,
|
||||
input: [...firstInput, { role: "assistant", content: [{ type: "output_text", text: "Hello" }] }, steer],
|
||||
})
|
||||
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
|
||||
),
|
||||
)
|
||||
const steer = { role: "user", content: [{ type: "input_text", text: "Actually, be brief" }] }
|
||||
const next = continuationDriver({
|
||||
type: "response.create",
|
||||
model: "gpt-5.2",
|
||||
store: false,
|
||||
input: [...firstInput, { role: "assistant", content: [{ type: "output_text", text: "Hello" }] }, steer],
|
||||
})
|
||||
|
||||
const continued = yield* next.create(saved)
|
||||
const continued = yield* next.create(saved)
|
||||
|
||||
expect(continued.mode).toBe("incremental")
|
||||
expect(ProviderShared.decodeJson(continued.message)).toMatchObject({
|
||||
previous_response_id: "resp_1",
|
||||
input: [steer],
|
||||
})
|
||||
}),
|
||||
),
|
||||
expect(continued.mode).toBe("incremental")
|
||||
expect(ProviderShared.decodeJson(continued.message)).toMatchObject({
|
||||
previous_response_id: "resp_1",
|
||||
input: [steer],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues streamed reasoning when completion re-encrypts the same item", () =>
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode/client/promise"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { installStressSessionTabs, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 }, serviceWorkers: "block" })
|
||||
|
||||
test("keeps five loaded workspace tabs visible and reactive through repeated switches", async ({ page }, info) => {
|
||||
const sessions = Array.from({ length: 5 }, (_, index) => ({
|
||||
...fixture.sessions[0]!,
|
||||
id: `ses_workspace_cycle_${index}`,
|
||||
directory: `${fixture.directory}/worktree-${index}`,
|
||||
title: `Workspace session ${index}`,
|
||||
}))
|
||||
const events: OpenCodeEvent[] = []
|
||||
await mockOpenCodeServer(page, {
|
||||
...fixture,
|
||||
sessions,
|
||||
pageMessages: (id) => ({
|
||||
items: [
|
||||
{ id: `msg_user_${id}`, type: "user", text: `Prompt for ${id}`, time: { created: 1 } },
|
||||
{
|
||||
id: `msg_assistant_${id}`,
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "claude-opus-4-6", providerID: "opencode" },
|
||||
time: { created: 2, completed: 3 },
|
||||
content: [{ type: "text", text: `Answer for ${id}` }],
|
||||
},
|
||||
] satisfies SessionMessageInfo[],
|
||||
}),
|
||||
events: () => events.splice(0),
|
||||
})
|
||||
await page.route("**/api/location?*", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
directory: new URL(route.request().url()).searchParams.get("location[directory]"),
|
||||
project: { id: fixture.project.id, directory: fixture.directory, canonical: fixture.directory },
|
||||
},
|
||||
}),
|
||||
)
|
||||
await installStressSessionTabs(page, { sessionIDs: sessions.map((session) => session.id) })
|
||||
await page.goto(stressSessionHref(sessions[0]!.id))
|
||||
await expect(page.getByText(`Answer for ${sessions[0]!.id}`, { exact: true })).toBeVisible()
|
||||
|
||||
for (const session of [...sessions.slice(1), ...sessions, ...sessions.toReversed()]) {
|
||||
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(session.id)}"]`).click()
|
||||
await expect(page.locator(`[data-timeline-part-id="msg_assistant_${session.id}:text:0"]`)).toBeVisible()
|
||||
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
|
||||
}
|
||||
const active = sessions[0]!
|
||||
events.push({
|
||||
id: "evt_workspace_cycle_update",
|
||||
created: 4,
|
||||
type: "session.text.ended",
|
||||
location: { directory: active.directory },
|
||||
durable: { aggregateID: active.id, seq: 0, version: 1 },
|
||||
data: {
|
||||
sessionID: active.id,
|
||||
assistantMessageID: `msg_assistant_${active.id}`,
|
||||
ordinal: 0,
|
||||
text: "Still receiving updates",
|
||||
},
|
||||
})
|
||||
await expect(page.getByText("Still receiving updates", { exact: true })).toBeVisible()
|
||||
await page.screenshot({ path: info.outputPath("workspace-tabs.png") })
|
||||
})
|
||||
@@ -18,7 +18,6 @@ export function createTimelineCache(
|
||||
visible: Accessor<boolean>,
|
||||
) {
|
||||
const owner = getOwner()
|
||||
let workspace = untrack(session.identity.workspaceKey)
|
||||
const cache = createScopedCache(
|
||||
(key) =>
|
||||
createRoot((dispose) => {
|
||||
@@ -52,18 +51,8 @@ export function createTimelineCache(
|
||||
{ maxEntries: 16, dispose: (entry) => entry.dispose() },
|
||||
)
|
||||
onCleanup(cache.clear)
|
||||
const syncWorkspace = (key: string) => {
|
||||
if (workspace === key) return
|
||||
workspace = key
|
||||
cache.clear()
|
||||
}
|
||||
// Providers follow the selected Location even while its history is loading.
|
||||
// Dispose detached views before their effects can read the new Location.
|
||||
createComputed(on(session.identity.workspaceKey, syncWorkspace, { defer: true }))
|
||||
return () => {
|
||||
// A tab's render can run before the workspace watcher in the same batch.
|
||||
// Clear the old workspace here, and let that later watcher keep this view.
|
||||
syncWorkspace(session.identity.workspaceKey())
|
||||
return cache.get(session.identity.sessionKey()).value
|
||||
}
|
||||
createComputed(on(session.identity.workspaceKey, cache.clear, { defer: true }))
|
||||
return () => cache.get(session.identity.sessionKey()).value
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { SessionMessageInfo } from "@opencode/client/promise"
|
||||
import { batch, createMemo, createRoot, onCleanup } from "solid-js"
|
||||
import { createMemo, createRoot, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { ServerScope, SessionRouteKey, SessionStateKey } from "../src/runtime/server/scope"
|
||||
import { createTimelineCache } from "../src/session/timeline/cache"
|
||||
@@ -144,35 +144,6 @@ test("disposes views on workspace changes while the destination is not rendered"
|
||||
expect(input.disposed).toEqual(["ses_a", "ses_a"])
|
||||
})
|
||||
|
||||
for (const order of ["session-first", "workspace-first"] as const) {
|
||||
test(`keeps views live across five workspaces when updates are ${order}`, () => {
|
||||
const input = setup()
|
||||
const render = createRoot((dispose) => ({ selected: createMemo(input.cache), dispose }))
|
||||
const visited = ["ses_a"]
|
||||
try {
|
||||
;["ses_b", "ses_c", "ses_d", "ses_e", "ses_a", "ses_c", "ses_b", "ses_e", "ses_d", "ses_a"].forEach(
|
||||
(id, index) => {
|
||||
batch(() => {
|
||||
if (order === "workspace-first") input.setState("directory", `/repo/${id}`)
|
||||
input.setState("id", id)
|
||||
if (order === "session-first") input.setState("directory", `/repo/${id}`)
|
||||
})
|
||||
expect(input.disposed).toEqual(visited)
|
||||
input.setState("messages", id, [
|
||||
{ id: `msg_live_${index}`, type: "user", text: "Live update", time: { created: index + 3 } },
|
||||
])
|
||||
expect((render.selected() as HTMLDivElement).dataset.messages).toBe(`msg_live_${index}`)
|
||||
expect(input.views.get(id)!.active()).toBe(true)
|
||||
visited.push(id)
|
||||
},
|
||||
)
|
||||
} finally {
|
||||
render.dispose()
|
||||
input.dispose()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test("evicts the least recently selected view and disposes all retained owners", () => {
|
||||
const input = setup()
|
||||
try {
|
||||
|
||||
@@ -1166,18 +1166,6 @@ export type MessageListInput = {
|
||||
readonly limit?: number | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly cursor?: string | undefined
|
||||
readonly type?:
|
||||
| "agent-switched"
|
||||
| "model-switched"
|
||||
| "location-switched"
|
||||
| "user"
|
||||
| "synthetic"
|
||||
| "system"
|
||||
| "skill"
|
||||
| "shell"
|
||||
| "assistant"
|
||||
| "compaction"
|
||||
| undefined
|
||||
}
|
||||
export type MessageListOutput = {
|
||||
readonly data: ReadonlyArray<SessionMessage.Info>
|
||||
|
||||
@@ -757,7 +757,7 @@ const EndpointMessageList = (raw: RawClient["server.message"]) => (input: Messag
|
||||
preserveEffect<MessageListOutput>()(
|
||||
raw["session.messages"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
query: { limit: input["limit"], order: input["order"], cursor: input["cursor"], type: input["type"] },
|
||||
query: { limit: input["limit"], order: input["order"], cursor: input["cursor"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
|
||||
@@ -1015,7 +1015,7 @@ export function make(options: ClientOptions) {
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/message`,
|
||||
query: { limit: input["limit"], order: input["order"], cursor: input["cursor"], type: input["type"] },
|
||||
query: { limit: input["limit"], order: input["order"], cursor: input["cursor"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401, 404, 500],
|
||||
empty: false,
|
||||
|
||||
@@ -4348,70 +4348,17 @@ export type MessageListInput = {
|
||||
readonly limit?: number | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly cursor?: string | undefined
|
||||
readonly type?:
|
||||
| "agent-switched"
|
||||
| "model-switched"
|
||||
| "location-switched"
|
||||
| "user"
|
||||
| "synthetic"
|
||||
| "system"
|
||||
| "skill"
|
||||
| "shell"
|
||||
| "assistant"
|
||||
| "compaction"
|
||||
| undefined
|
||||
}["limit"]
|
||||
readonly order?: {
|
||||
readonly limit?: number | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly cursor?: string | undefined
|
||||
readonly type?:
|
||||
| "agent-switched"
|
||||
| "model-switched"
|
||||
| "location-switched"
|
||||
| "user"
|
||||
| "synthetic"
|
||||
| "system"
|
||||
| "skill"
|
||||
| "shell"
|
||||
| "assistant"
|
||||
| "compaction"
|
||||
| undefined
|
||||
}["order"]
|
||||
readonly cursor?: {
|
||||
readonly limit?: number | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly cursor?: string | undefined
|
||||
readonly type?:
|
||||
| "agent-switched"
|
||||
| "model-switched"
|
||||
| "location-switched"
|
||||
| "user"
|
||||
| "synthetic"
|
||||
| "system"
|
||||
| "skill"
|
||||
| "shell"
|
||||
| "assistant"
|
||||
| "compaction"
|
||||
| undefined
|
||||
}["cursor"]
|
||||
readonly type?: {
|
||||
readonly limit?: number | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly cursor?: string | undefined
|
||||
readonly type?:
|
||||
| "agent-switched"
|
||||
| "model-switched"
|
||||
| "location-switched"
|
||||
| "user"
|
||||
| "synthetic"
|
||||
| "system"
|
||||
| "skill"
|
||||
| "shell"
|
||||
| "assistant"
|
||||
| "compaction"
|
||||
| undefined
|
||||
}["type"]
|
||||
}
|
||||
|
||||
export type MessageListOutput = SessionMessagesResponse
|
||||
|
||||
@@ -31,8 +31,8 @@ ultimate source of truth.
|
||||
- [x] `null`, `undefined`, booleans, finite and non-finite numbers, and strings.
|
||||
- [x] Array literals, including holes and spread from arrays, strings, Maps, Sets, URLSearchParams, custom synchronous
|
||||
iterators, and synchronous generators.
|
||||
- [x] Object literals with shorthand, computed string/number keys, and spread following ToObject: data objects and
|
||||
arrays copy own enumerable keys, strings copy index keys, and other values contribute nothing.
|
||||
- [x] Object literals with shorthand, computed string/number keys, and spread from plain data objects; `null` and
|
||||
`undefined` are no-ops, while arrays are rejected.
|
||||
- [x] Template literals with interpolation.
|
||||
- [x] Regular-expression literals.
|
||||
- [x] `NaN` and `Infinity` globals.
|
||||
@@ -44,17 +44,14 @@ ultimate source of truth.
|
||||
|
||||
## Bindings and destructuring
|
||||
|
||||
- [x] `const`, `let`, and `var` declarations.
|
||||
- [x] `const`, `let`, and accepted `var` declarations.
|
||||
- [x] Object and array destructuring in declarations, parameters, assignment expressions, and `for...of` bindings.
|
||||
- [x] Nested patterns, defaults, elisions, and rest elements.
|
||||
- [x] Assignment to identifiers, plain-object fields, non-negative integer array indexes, and writable URL
|
||||
fields.
|
||||
- [x] Direct function declarations are hoisted in program and block statement lists.
|
||||
- [x] Parameter defaults observe a temporal dead zone for later parameters.
|
||||
- [x] `var` is function-scoped and hoisted: names declared anywhere in a function or program body, including loop
|
||||
heads, blocks, `switch` cases, and `try`/`catch`, read as `undefined` before their statement runs; redeclaration
|
||||
assigns the one binding; a same-named parameter keeps its argument; closures in parameter defaults see outer
|
||||
names rather than body `var`s.
|
||||
- [ ] JavaScript-correct function scoping, hoisting, and redeclaration for accepted `var` declarations.
|
||||
- [x] Predeclare `let` and `const` bindings in every lexical scope, including program/block bodies, switch bodies, and
|
||||
loop headers, so reads before initialization and self- or cross-referential initializers observe the JavaScript
|
||||
temporal dead zone.
|
||||
@@ -73,7 +70,7 @@ ultimate source of truth.
|
||||
- [x] `for`, `while`, and `do...while`.
|
||||
- [x] `for...of` over arrays, strings, Maps, Sets, URLSearchParams, custom synchronous iterators, and confined
|
||||
synchronous generators. Abrupt completion invokes the iterator's optional `return()`.
|
||||
- [x] `for...in` over own keys of plain objects, arrays, strings, and tool references; other values iterate nothing.
|
||||
- [x] `for...in` over own keys of plain objects, arrays, and tool references.
|
||||
- [x] Unlabeled `break` and `continue`.
|
||||
- [x] `try`, `catch`, optional catch bindings, and `finally`.
|
||||
- [x] `throw` with arbitrary values.
|
||||
@@ -213,15 +210,12 @@ ultimate source of truth.
|
||||
primitive wrapper objects (`Object(1)`) are rejected explicitly.
|
||||
- [x] Computed property names and object spread.
|
||||
- [x] `Object.keys`, `Object.values`, `Object.entries`, `Object.hasOwn`, `Object.assign`, and `Object.fromEntries`, with
|
||||
synchronous iterator support for `fromEntries`. Sources follow ToObject: strings enumerate by index, other
|
||||
primitives and wrappers contribute nothing, and `null`/`undefined` throw. `Object.assign` accepts array
|
||||
targets for index keys only; a primitive target is a `TypeError` rather than a boxed object.
|
||||
synchronous iterator support for `fromEntries`.
|
||||
- [x] `Object.keys` over arrays and tool references.
|
||||
- [x] Object identity is preserved by in-CodeMode Object helpers.
|
||||
- [x] `__proto__`, `constructor`, and `prototype` are ordinary own data keys. `x.constructor` without an own key resolves
|
||||
to the owning built-in (`[].constructor === Array`, `new TypeError().constructor === TypeError`); prototype objects
|
||||
are not observable, so `[].__proto__` and `Object.prototype` read as `undefined` and `o.__proto__ = x` sets an own
|
||||
field.
|
||||
- [x] `__proto__`, `constructor`, and `prototype` are ordinary own data keys. Prototype machinery is not observable:
|
||||
data objects have no prototype, so `({}).constructor` and `[].__proto__` read as `undefined` and `o.__proto__ = x`
|
||||
sets an own field.
|
||||
- [x] Circular references are rejected when created (`o.self = o`, `array.push(array)`), not at serialization as in JS.
|
||||
- [x] `Object.is` for supported data values.
|
||||
- [x] `Object.groupBy` over finite collections and custom synchronous iterators/generators, with string-key coercion
|
||||
@@ -253,13 +247,12 @@ ultimate source of truth.
|
||||
## Strings
|
||||
|
||||
- [x] Case/normalization: `toLowerCase`, `toUpperCase`, `normalize`.
|
||||
- [x] Trimming: `trim`, `trimStart`, and `trimEnd`, plus the Annex B `trimLeft` and `trimRight` aliases.
|
||||
- [x] Trimming: `trim`, `trimStart`, and `trimEnd`.
|
||||
- [x] Searching/tests: `includes`, `startsWith`, `endsWith`, `indexOf`, `lastIndexOf`, and `search`.
|
||||
- [x] Slicing/access: `slice`, `substring`, Annex B `substr`, `at`, `charAt`, `charCodeAt`, and `codePointAt`.
|
||||
- [x] Slicing/access: `slice`, `substring`, `at`, `charAt`, `charCodeAt`, and `codePointAt`.
|
||||
- [x] Construction/transformation: `split`, `concat`, `repeat`, `padStart`, `padEnd`, `replace`, and `replaceAll`.
|
||||
- [x] Regular-expression integration: `match`, materialized `matchAll`, `replace`, `replaceAll`, `split`, and `search`.
|
||||
- [x] `localeCompare`; locale and options arguments are currently ignored.
|
||||
- [x] `isWellFormed` and `toWellFormed`.
|
||||
- [x] `toString`, `length`, numeric indexing, spread, and `for...of` by Unicode code point.
|
||||
- [x] Static `String.fromCharCode` and `String.fromCodePoint`.
|
||||
- [x] Native argument coercion for supported String methods; for example, `includes(1)` and `slice("1")` coerce like
|
||||
@@ -320,7 +313,6 @@ ultimate source of truth.
|
||||
- [x] Local and UTC Date setters, including native argument coercion, mutation, rollover, invalid-Date recovery, and
|
||||
`TimeClip` behavior.
|
||||
- [x] `Date.prototype.toUTCString` and its `toGMTString` alias.
|
||||
- [x] `toDateString` and `toTimeString` in the host's local timezone.
|
||||
- [x] Native one-argument Date coercion for supported values, including booleans, null, arrays, and plain objects.
|
||||
- [x] Native Date loose-equality and default primitive-coercion semantics, using CodeMode's deterministic ISO string
|
||||
representation for the string primitive.
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,11 +118,9 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
|
||||
result = value.trim()
|
||||
break
|
||||
case "trimStart":
|
||||
case "trimLeft":
|
||||
result = value.trimStart()
|
||||
break
|
||||
case "trimEnd":
|
||||
case "trimRight":
|
||||
result = value.trimEnd()
|
||||
break
|
||||
// Locale/options are deliberately unsupported; comparison uses the host default locale.
|
||||
@@ -243,15 +241,6 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
|
||||
case "substring":
|
||||
result = value.substring(optNum(0) ?? 0, optNum(1))
|
||||
break
|
||||
case "substr":
|
||||
result = value.substr(optNum(0) ?? 0, optNum(1))
|
||||
break
|
||||
case "isWellFormed":
|
||||
result = value.isWellFormed()
|
||||
break
|
||||
case "toWellFormed":
|
||||
result = value.toWellFormed()
|
||||
break
|
||||
case "charCodeAt":
|
||||
result = value.charCodeAt(optNum(0) ?? 0)
|
||||
break
|
||||
|
||||
@@ -32,17 +32,6 @@ export class PromiseRuntime<R> {
|
||||
|
||||
constructor(private readonly scope: Scope.Scope) {}
|
||||
|
||||
// Resolution bodies need the promise's own identity to reject `resolve(promise)` self-resolution.
|
||||
createWithSelf(
|
||||
body: (self: { promise?: Values.Promise }) => Effect.Effect<unknown, unknown, R>,
|
||||
): Effect.Effect<Values.Promise, never, R> {
|
||||
const self: { promise?: Values.Promise } = {}
|
||||
return Effect.map(this.create(body(self)), (promise) => {
|
||||
self.promise = promise
|
||||
return promise
|
||||
})
|
||||
}
|
||||
|
||||
create(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<Values.Promise, never, R> {
|
||||
return Effect.suspend(() => {
|
||||
// Allocate before forking so reruns get distinct IDs and diagnostics retain creation order.
|
||||
@@ -137,7 +126,11 @@ export const resolvePromise = <R>(
|
||||
node: AstNode,
|
||||
): Effect.Effect<Values.Promise, never, R> => {
|
||||
if (value instanceof Values.Promise) return Effect.succeed(value)
|
||||
return promises.createWithSelf((self) => resolvePromiseValue(runner, value, node, self))
|
||||
const box: { promise?: Values.Promise } = {}
|
||||
return Effect.map(promises.create(resolvePromiseValue(runner, value, node, box)), (promise) => {
|
||||
box.promise = promise
|
||||
return promise
|
||||
})
|
||||
}
|
||||
|
||||
const promiseStatics = ["all", "allSettled", "race", "any", "resolve", "reject"] as const
|
||||
@@ -261,9 +254,11 @@ const constructPromise = <R>(
|
||||
}
|
||||
return Effect.gen(function* () {
|
||||
const deferred = Deferred.makeUnsafe<unknown, unknown>()
|
||||
const promise = yield* promises.createWithSelf((self) =>
|
||||
Effect.flatMap(Deferred.await(deferred), (value) => resolvePromiseValue(runner, value, node, self)),
|
||||
const box: { promise?: Values.Promise } = {}
|
||||
const promise = yield* promises.create(
|
||||
Effect.flatMap(Deferred.await(deferred), (value) => resolvePromiseValue(runner, value, node, box)),
|
||||
)
|
||||
box.promise = promise
|
||||
const resolve = capability("resolve", (value) => Deferred.doneUnsafe(deferred, Exit.succeed(value)))
|
||||
const reject = capability("reject", (value) => Deferred.doneUnsafe(deferred, Exit.fail(new ProgramThrow(value))))
|
||||
const executed = yield* Effect.exit(runner.invokeFunction(executor, [resolve, reject]))
|
||||
@@ -315,16 +310,19 @@ const chainReaction = <R>(
|
||||
method: string,
|
||||
node: AstNode,
|
||||
): Effect.Effect<Values.Promise, never, R> => {
|
||||
return promises.createWithSelf((self) =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* reactionExit(promises, source)
|
||||
const handler = Exit.isSuccess(exit) ? onFulfilled : onRejected
|
||||
if (handler === undefined) return yield* exit
|
||||
const input = Exit.isSuccess(exit) ? exit.value : caughtErrorValue(Cause.squash(exit.cause))
|
||||
const result = yield* applyCollectionCallback(runner, handler, method, node)([input])
|
||||
return yield* resolvePromiseValue(runner, result, node, self)
|
||||
}),
|
||||
)
|
||||
const box: { promise?: Values.Promise } = {}
|
||||
const body = Effect.gen(function* () {
|
||||
const exit = yield* reactionExit(promises, source)
|
||||
const handler = Exit.isSuccess(exit) ? onFulfilled : onRejected
|
||||
if (handler === undefined) return yield* exit
|
||||
const input = Exit.isSuccess(exit) ? exit.value : caughtErrorValue(Cause.squash(exit.cause))
|
||||
const result = yield* applyCollectionCallback(runner, handler, method, node)([input])
|
||||
return yield* resolvePromiseValue(runner, result, node, box)
|
||||
})
|
||||
return Effect.map(promises.create(body), (derived) => {
|
||||
box.promise = derived
|
||||
return derived
|
||||
})
|
||||
}
|
||||
|
||||
const chainFinally = <R>(
|
||||
|
||||
@@ -108,12 +108,3 @@ export const typeofValue = (value: unknown): string => {
|
||||
if (value instanceof ToolReference) return value.path.length > 0 ? "function" : "object"
|
||||
return typeof value
|
||||
}
|
||||
|
||||
const MAX_ARRAY_LENGTH = 4_294_967_295
|
||||
|
||||
export const parseArrayIndex = (key: string | number): number | undefined => {
|
||||
const property = String(key)
|
||||
if (!/^(0|[1-9]\d*)$/.test(property)) return undefined
|
||||
const index = Number(property)
|
||||
return index < MAX_ARRAY_LENGTH ? index : undefined
|
||||
}
|
||||
|
||||
@@ -75,7 +75,6 @@ import {
|
||||
containsOpaqueReference,
|
||||
describeValue,
|
||||
isRuntimeReference,
|
||||
parseArrayIndex,
|
||||
rejectCircularInsertion,
|
||||
typeofValue,
|
||||
} from "./references.js"
|
||||
@@ -86,20 +85,16 @@ import { numberMethods } from "../stdlib/number.js"
|
||||
import { constructRegExp, regexpMethods, regexpProperties } from "../stdlib/regexp.js"
|
||||
import { stringMethods } from "../stdlib/string.js"
|
||||
import { uriArgument, urlMethods, urlProperties, urlSearchParamsMethods, urlWritableProperties } from "../stdlib/url.js"
|
||||
import { enumerableSource } from "../stdlib/object.js"
|
||||
import { coerceToNumber, coerceToString, compoundOperators, errorBrandName } from "../stdlib/value.js"
|
||||
import { coerceToNumber, coerceToString, compoundOperators } from "../stdlib/value.js"
|
||||
import { Values } from "../values.js"
|
||||
|
||||
// What a loop does with its body's result: exit with a StatementResult, or undefined to keep iterating.
|
||||
// Unlabelled break ends this loop; a label the loop does not carry propagates outward.
|
||||
const loopExit = (result: StatementResult, labels: ReadonlySet<string> | undefined): StatementResult | undefined => {
|
||||
if (result.kind === "return") return result
|
||||
if (result.kind === "break") {
|
||||
if (result.label !== undefined && !labels?.has(result.label)) return result
|
||||
return { kind: "none" }
|
||||
}
|
||||
if (result.kind === "continue" && result.label !== undefined && !labels?.has(result.label)) return result
|
||||
return undefined
|
||||
const MAX_ARRAY_LENGTH = 4_294_967_295
|
||||
|
||||
const parseArrayIndex = (key: string | number): number | undefined => {
|
||||
const property = String(key)
|
||||
if (!/^(0|[1-9]\d*)$/.test(property)) return undefined
|
||||
const index = Number(property)
|
||||
return index < MAX_ARRAY_LENGTH ? index : undefined
|
||||
}
|
||||
|
||||
const calleeDescription = (callee: Expression | Super | undefined): string => {
|
||||
@@ -118,25 +113,6 @@ const calleeDescription = (callee: Expression | Super | undefined): string => {
|
||||
return "The called value"
|
||||
}
|
||||
|
||||
const hasOwn = (value: unknown, key: PropertyKey): boolean =>
|
||||
value !== null && typeof value === "object" && Object.hasOwn(value, key)
|
||||
|
||||
const constructorName = (value: unknown): string | undefined => {
|
||||
if (typeof value === "string") return "String"
|
||||
if (typeof value === "number") return "Number"
|
||||
if (typeof value === "boolean") return "Boolean"
|
||||
if (Array.isArray(value)) return "Array"
|
||||
if (value instanceof Values.Date) return "Date"
|
||||
if (value instanceof Values.RegExp) return "RegExp"
|
||||
if (value instanceof Values.Map) return "Map"
|
||||
if (value instanceof Values.Set) return "Set"
|
||||
if (value instanceof Values.URL) return "URL"
|
||||
if (value instanceof Values.URLSearchParams) return "URLSearchParams"
|
||||
if (value instanceof Values.Promise) return "Promise"
|
||||
if (value === null || typeof value !== "object" || isRuntimeReference(value)) return undefined
|
||||
return errorBrandName(value) ?? "Object"
|
||||
}
|
||||
|
||||
const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean => {
|
||||
if (rhs instanceof HostFunction && rhs.instanceOf !== undefined) return rhs.instanceOf(lhs)
|
||||
throw new InterpreterRuntimeError(
|
||||
@@ -170,49 +146,6 @@ const collectPatternNames = (pattern: Pattern, out: Array<string> = []): Array<s
|
||||
return out
|
||||
}
|
||||
|
||||
// `var` names declared anywhere in a function body except inside nested functions, which own theirs.
|
||||
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
|
||||
@@ -273,8 +206,6 @@ const promiseResolutionNode: AstNode = { type: "PromiseResolution", start: 0, en
|
||||
/** One program execution: the tool bridge, promise scheduler, captured logs, and the global scope built once. */
|
||||
export class Runtime<R> {
|
||||
readonly runner: Runner<R>
|
||||
/** Built-in globals by name, unaffected by program shadowing. */
|
||||
readonly builtins: ReadonlyMap<string, unknown>
|
||||
private readonly root: Frame<R>
|
||||
|
||||
constructor(
|
||||
@@ -293,8 +224,7 @@ export class Runtime<R> {
|
||||
settlePromise: (promise) => this.root.settlePromise(promise),
|
||||
syncIterator: (value, node) => this.root.syncIterator(value, node),
|
||||
}
|
||||
this.builtins = new Map(globals(this))
|
||||
for (const [name, value] of this.builtins) globalScope.set(name, { mutable: false, value })
|
||||
for (const [name, value] of globals(this)) globalScope.set(name, { mutable: false, value })
|
||||
}
|
||||
|
||||
run(program: Program): Effect.Effect<unknown, unknown, R> {
|
||||
@@ -319,7 +249,6 @@ class Frame<R> {
|
||||
return Effect.gen(function* () {
|
||||
self.predeclareLexical(program.body)
|
||||
self.hoistFunctions(program.body)
|
||||
self.hoistVars(program.body)
|
||||
let value: unknown = undefined
|
||||
for (const [index, statement] of program.body.entries()) {
|
||||
if (index === program.body.length - 1 && statement.type === "ExpressionStatement") {
|
||||
@@ -443,18 +372,6 @@ class Frame<R> {
|
||||
}
|
||||
}
|
||||
|
||||
// Hoisted `var` bindings start undefined, or copy a same-named parameter. Function bodies hoist
|
||||
// into their own scope above the parameters so closures in parameter defaults keep seeing outer names.
|
||||
private hoistVars(statements: ReadonlyArray<Statement | ModuleDeclaration>, parameters?: Map<string, Binding>): void {
|
||||
const scope = this.scopes.current()
|
||||
for (const statement of statements) {
|
||||
for (const name of collectVarNames(statement)) {
|
||||
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
|
||||
@@ -534,8 +451,21 @@ class Frame<R> {
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
while (yield* self.evaluateExpression(node.test)) {
|
||||
const exit = loopExit(yield* self.evaluateStatement(node.body), labels)
|
||||
if (exit !== undefined) return exit
|
||||
const result = yield* self.evaluateStatement(node.body)
|
||||
|
||||
if (result.kind === "continue") {
|
||||
if (result.label !== undefined && !labels?.has(result.label)) return result
|
||||
continue
|
||||
}
|
||||
|
||||
if (result.kind === "break") {
|
||||
if (result.label !== undefined && !labels?.has(result.label)) return result
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
}
|
||||
|
||||
if (result.kind === "return") {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
@@ -549,8 +479,21 @@ class Frame<R> {
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
do {
|
||||
const exit = loopExit(yield* self.evaluateStatement(node.body), labels)
|
||||
if (exit !== undefined) return exit
|
||||
const result = yield* self.evaluateStatement(node.body)
|
||||
|
||||
if (result.kind === "continue") {
|
||||
if (result.label !== undefined && !labels?.has(result.label)) return result
|
||||
continue
|
||||
}
|
||||
|
||||
if (result.kind === "break") {
|
||||
if (result.label !== undefined && !labels?.has(result.label)) return result
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
}
|
||||
|
||||
if (result.kind === "return") {
|
||||
return result
|
||||
}
|
||||
} while (yield* self.evaluateExpression(node.test))
|
||||
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
@@ -596,13 +539,27 @@ class Frame<R> {
|
||||
nextIteration()
|
||||
|
||||
while (testNode ? yield* self.evaluateExpression(testNode) : true) {
|
||||
const exit = loopExit(yield* self.evaluateStatement(node.body), labels)
|
||||
if (exit !== undefined) return exit
|
||||
const result = yield* self.evaluateStatement(node.body)
|
||||
|
||||
if (result.kind === "return") {
|
||||
return result
|
||||
}
|
||||
|
||||
if (result.kind === "break") {
|
||||
if (result.label !== undefined && !labels?.has(result.label)) return result
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
}
|
||||
|
||||
if (result.kind === "continue" && result.label !== undefined && !labels?.has(result.label)) return result
|
||||
|
||||
nextIteration()
|
||||
if (updateNode) {
|
||||
yield* self.evaluateExpression(updateNode)
|
||||
}
|
||||
|
||||
if (result.kind === "continue") {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
@@ -645,12 +602,10 @@ class Frame<R> {
|
||||
|
||||
const evaluateBody = (value: unknown) =>
|
||||
Effect.gen(function* () {
|
||||
if (declared?.lexical) {
|
||||
if (declared) {
|
||||
self.scopes.push()
|
||||
self.predeclarePattern(declared.pattern, declared.mutable, left)
|
||||
yield* self.declarePattern(declared.pattern, value, declared.mutable, left, true)
|
||||
} else if (declared) {
|
||||
yield* self.assignPattern(declared.pattern, value, left)
|
||||
if (declared.lexical) self.predeclarePattern(declared.pattern, declared.mutable, left)
|
||||
yield* self.declarePattern(declared.pattern, value, declared.mutable, left, declared.lexical)
|
||||
} else if (assignment) {
|
||||
yield* self.assignPattern(assignment, value, left)
|
||||
}
|
||||
@@ -658,7 +613,7 @@ class Frame<R> {
|
||||
}).pipe(
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
if (declared?.lexical) self.scopes.pop()
|
||||
if (declared) self.scopes.pop()
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -677,10 +632,22 @@ class Frame<R> {
|
||||
}
|
||||
return yield* Effect.failCause(bodyExit.cause)
|
||||
}
|
||||
const exit = loopExit(bodyExit.value, labels)
|
||||
if (exit !== undefined) {
|
||||
const result = bodyExit.value
|
||||
|
||||
if (result.kind === "return") {
|
||||
yield* close()
|
||||
return exit
|
||||
return result
|
||||
}
|
||||
|
||||
if (result.kind === "break") {
|
||||
yield* close()
|
||||
if (result.label !== undefined && !labels?.has(result.label)) return result
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
}
|
||||
|
||||
if (result.kind === "continue" && result.label !== undefined && !labels?.has(result.label)) {
|
||||
yield* close()
|
||||
return result
|
||||
}
|
||||
}
|
||||
}).pipe(
|
||||
@@ -863,11 +830,17 @@ class Frame<R> {
|
||||
throw new InterpreterRuntimeError(`${context} must be a function.`, node).as("TypeError")
|
||||
}
|
||||
|
||||
// for...in over null/undefined iterates nothing, like JS.
|
||||
private enumerableKeys(value: unknown, node: AstNode): Array<string> {
|
||||
if (value instanceof ToolReference) return [...this.runtime.toolKeys(value.path)]
|
||||
if (value === null || value === undefined) return []
|
||||
return Object.keys(enumerableSource("for...in", value, node))
|
||||
private enumerableKeys(value: unknown): Array<string> | undefined {
|
||||
if (value instanceof ToolReference) {
|
||||
return [...this.runtime.toolKeys(value.path)]
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return Object.keys(value)
|
||||
}
|
||||
if (value !== null && typeof value === "object" && !isRuntimeReference(value)) {
|
||||
return Object.keys(value)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
private evaluateForInStatement(
|
||||
@@ -883,7 +856,13 @@ class Frame<R> {
|
||||
if (declared?.lexical) self.predeclarePattern(declared.pattern, declared.mutable, left)
|
||||
const right = yield* self.evaluateExpression(node.right)
|
||||
|
||||
const keys = self.enumerableKeys(right, node.right)
|
||||
const keys = self.enumerableKeys(right)
|
||||
if (keys === undefined) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"for...in requires a plain object, array, or tools reference. Use for...of for arrays/strings/Maps/Sets, or Object.keys(value) for a key list.",
|
||||
node,
|
||||
)
|
||||
}
|
||||
|
||||
if (left.type !== "Identifier" && left.type !== "VariableDeclaration") {
|
||||
throw new InterpreterRuntimeError("Unsupported for...in binding.", left)
|
||||
@@ -892,12 +871,10 @@ class Frame<R> {
|
||||
|
||||
for (const key of keys) {
|
||||
const result = yield* Effect.gen(function* () {
|
||||
if (declared?.lexical) {
|
||||
if (declared) {
|
||||
self.scopes.push()
|
||||
self.predeclarePattern(declared.pattern, declared.mutable, left)
|
||||
yield* self.declarePattern(declared.pattern, key, declared.mutable, left, true)
|
||||
} else if (declared) {
|
||||
yield* self.assignPattern(declared.pattern, key, left)
|
||||
if (declared.lexical) self.predeclarePattern(declared.pattern, declared.mutable, left)
|
||||
yield* self.declarePattern(declared.pattern, key, declared.mutable, left, declared.lexical)
|
||||
} else if (assignmentName) {
|
||||
self.scopes.set(assignmentName, key, left)
|
||||
}
|
||||
@@ -905,13 +882,24 @@ class Frame<R> {
|
||||
}).pipe(
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
if (declared?.lexical) self.scopes.pop()
|
||||
if (declared) self.scopes.pop()
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const exit = loopExit(result, labels)
|
||||
if (exit !== undefined) return exit
|
||||
if (result.kind === "return") {
|
||||
return result
|
||||
}
|
||||
|
||||
if (result.kind === "break") {
|
||||
if (result.label !== undefined && !labels?.has(result.label)) return result
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
}
|
||||
|
||||
if (result.kind === "continue") {
|
||||
if (result.label !== undefined && !labels?.has(result.label)) return result
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
@@ -1012,13 +1000,8 @@ class Frame<R> {
|
||||
}
|
||||
|
||||
const init = declaration.init
|
||||
// `var x` alone is a no-op: the binding was hoisted on function entry.
|
||||
if (kind === "var") {
|
||||
if (init) yield* self.assignPattern(declaration.id, yield* self.evaluateExpression(init), declaration)
|
||||
continue
|
||||
}
|
||||
const value = init ? yield* self.evaluateExpression(init) : undefined
|
||||
yield* self.declarePattern(declaration.id, value, kind !== "const", declaration, true)
|
||||
yield* self.declarePattern(declaration.id, value, kind !== "const", declaration, kind !== "var")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1639,8 +1622,6 @@ class Frame<R> {
|
||||
}
|
||||
|
||||
if (fn.body.type === "BlockStatement") {
|
||||
invocation.scopes.push()
|
||||
invocation.hoistVars(fn.body.body, paramScope)
|
||||
const result = yield* invocation.evaluateStatement(fn.body)
|
||||
return result.kind === "return" ? result.value : undefined
|
||||
}
|
||||
@@ -1913,10 +1894,16 @@ class Frame<R> {
|
||||
for (const property of node.properties) {
|
||||
if (property.type === "SpreadElement") {
|
||||
const spread = yield* self.evaluateExpression(property.argument)
|
||||
if (spread === null || spread === undefined) continue
|
||||
const from = enumerableSource("Object spread", spread, property)
|
||||
for (const [key, value] of Object.entries(from)) objectValue[key] = value
|
||||
if (typeof from === "object") copyIteratorSymbols(from, objectValue)
|
||||
if (spread === null || spread === undefined || Values.isValue(spread)) continue
|
||||
if (typeof spread !== "object" || Array.isArray(spread) || isRuntimeReference(spread)) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`Object spread requires a data object, received ${describeValue(spread)}.`,
|
||||
property,
|
||||
"InvalidDataValue",
|
||||
)
|
||||
}
|
||||
for (const [key, value] of Object.entries(spread)) objectValue[key] = value
|
||||
copyIteratorSymbols(spread, objectValue)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -2015,7 +2002,7 @@ class Frame<R> {
|
||||
|
||||
private getMemberReference(
|
||||
node: MemberExpression,
|
||||
operation: "read" | "write" | "delete" = "read",
|
||||
operation: "read" | "delete" = "read",
|
||||
): Effect.Effect<
|
||||
| MemberReference
|
||||
| ToolReference
|
||||
@@ -2056,12 +2043,6 @@ class Frame<R> {
|
||||
return new ComputedValue(objectValue.member(key, propertyNode))
|
||||
}
|
||||
|
||||
// Values have no prototype chain, so `.constructor` resolves to the owning built-in directly.
|
||||
if (operation === "read" && key === "constructor" && !hasOwn(objectValue, key)) {
|
||||
const name = constructorName(objectValue)
|
||||
if (name !== undefined) return new ComputedValue(self.runtime.builtins.get(name))
|
||||
}
|
||||
|
||||
if (typeof objectValue === "string") {
|
||||
if (key === "length") return new ComputedValue(objectValue.length)
|
||||
const index = typeof key === "symbol" ? undefined : parseArrayIndex(key)
|
||||
@@ -2217,7 +2198,7 @@ class Frame<R> {
|
||||
): Effect.Effect<unknown, unknown, R> {
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
const reference = yield* self.getMemberReference(node, "write")
|
||||
const reference = yield* self.getMemberReference(node)
|
||||
if (
|
||||
reference === OptionalShortCircuit ||
|
||||
reference instanceof ComputedValue ||
|
||||
|
||||
@@ -29,8 +29,6 @@ export const dateMethods = new Set([
|
||||
"toISOString",
|
||||
"toJSON",
|
||||
"toString",
|
||||
"toDateString",
|
||||
"toTimeString",
|
||||
"toUTCString",
|
||||
"toGMTString",
|
||||
"getFullYear",
|
||||
@@ -105,10 +103,6 @@ export const invokeDateMethod = (
|
||||
return Number.isFinite(value.time) ? hosted.toISOString() : null
|
||||
case "toString":
|
||||
return coerceToString(value)
|
||||
case "toDateString":
|
||||
return hosted.toDateString()
|
||||
case "toTimeString":
|
||||
return hosted.toTimeString()
|
||||
case "toUTCString":
|
||||
case "toGMTString":
|
||||
return hosted.toUTCString()
|
||||
|
||||
@@ -5,8 +5,6 @@ import { type AstNode, AsyncIteratorSymbol, InterpreterRuntimeError, IteratorSym
|
||||
import {
|
||||
containsOpaqueReference,
|
||||
describeValue,
|
||||
isRuntimeReference,
|
||||
parseArrayIndex,
|
||||
rejectCircularInsertion,
|
||||
typeofValue,
|
||||
} from "../interpreter/references.js"
|
||||
@@ -16,53 +14,28 @@ import { Values } from "../values.js"
|
||||
import { groupBy } from "./collections.js"
|
||||
import { coerceToString } from "./value.js"
|
||||
|
||||
// ToObject for enumeration. Strings return themselves: the host's Object.keys/entries/hasOwn index a
|
||||
// primitive string directly. Numbers, booleans, wrappers, and functions have no own enumerable keys.
|
||||
export const enumerableSource = (label: string, value: unknown, node: AstNode): Record<string, unknown> => {
|
||||
if (value === null || value === undefined) {
|
||||
throw new InterpreterRuntimeError(`${label} cannot convert ${describeValue(value)} to an object.`, node).as(
|
||||
"TypeError",
|
||||
)
|
||||
}
|
||||
if (value instanceof Values.Promise) {
|
||||
const requireObject = (name: string, input: unknown, node: AstNode): Record<string, unknown> => {
|
||||
if (Array.isArray(input)) return input as unknown as Record<string, unknown>
|
||||
if (Values.isValue(input)) return {}
|
||||
const prototype = input === null || typeof input !== "object" ? undefined : Object.getPrototypeOf(input)
|
||||
if (prototype !== null && prototype !== Object.prototype) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`${label} received an un-awaited Promise; await it before inspecting the result.`,
|
||||
`Object.${name} expects a data object or array, received ${describeValue(input)}.`,
|
||||
node,
|
||||
"InvalidDataValue",
|
||||
)
|
||||
}
|
||||
if (value instanceof ToolReference) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`${label} cannot read tool references: they are not plain data. Use Object.keys(tools) for names, or search({ query }) for signatures.`,
|
||||
node,
|
||||
"InvalidDataValue",
|
||||
)
|
||||
}
|
||||
if (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>
|
||||
return input as Record<string, unknown>
|
||||
}
|
||||
|
||||
export const objectAssign = (args: Array<unknown>, node: AstNode): unknown => {
|
||||
const target = args[0]
|
||||
// JS would box a primitive target; wrappers and primitives cannot hold fields here.
|
||||
if (target === null || typeof target !== "object" || Values.isValue(target) || isRuntimeReference(target)) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`Object.assign expects a data object or array target, received ${describeValue(target)}.`,
|
||||
node,
|
||||
).as("TypeError")
|
||||
if (target === null || typeof target !== "object" || Array.isArray(target) || Values.isValue(target)) {
|
||||
throw new InterpreterRuntimeError("Object.assign expects a data object target.", node)
|
||||
}
|
||||
const out = target as Record<string, unknown>
|
||||
const seen = new Set<object>()
|
||||
const guardedSet = (key: PropertyKey, item: unknown): void => {
|
||||
// Arrays hold only indexed elements, as with direct assignment; Reflect.set would otherwise
|
||||
// reach Array's length and Object.prototype's __proto__ setter.
|
||||
if (Array.isArray(out) && (typeof key === "symbol" || parseArrayIndex(key) === undefined)) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`Object.assign cannot assign '${String(key)}' to an array: only array indexes may be assigned.`,
|
||||
node,
|
||||
).as("TypeError")
|
||||
}
|
||||
rejectCircularInsertion(out, item, "Object.assign result", node, seen)
|
||||
if (!Reflect.set(out, key, item))
|
||||
throw new InterpreterRuntimeError(`Object.assign could not assign property '${String(key)}'.`, node).as(
|
||||
@@ -70,15 +43,18 @@ export const objectAssign = (args: Array<unknown>, node: AstNode): unknown => {
|
||||
)
|
||||
}
|
||||
for (const source of args.slice(1)) {
|
||||
if (source === null || source === undefined) continue
|
||||
const from = enumerableSource("Object.assign(...)", source, node)
|
||||
if (typeof from !== "object") {
|
||||
for (const [key, item] of Object.entries(from)) guardedSet(key, item)
|
||||
continue
|
||||
if (source === null || source === undefined || Values.isValue(source)) continue
|
||||
if (typeof source !== "object" || Array.isArray(source)) {
|
||||
throw new InterpreterRuntimeError("Object.assign expects data objects.", node)
|
||||
}
|
||||
for (const key of Reflect.ownKeys(from)) {
|
||||
if (typeof key === "symbol" && key !== AsyncIteratorSymbol && key !== IteratorSymbol) continue
|
||||
if (Object.prototype.propertyIsEnumerable.call(from, key)) guardedSet(key, Reflect.get(from, key))
|
||||
for (const key of Reflect.ownKeys(source)) {
|
||||
if (typeof key === "string") {
|
||||
if (Object.prototype.propertyIsEnumerable.call(source, key)) guardedSet(key, Reflect.get(source, key))
|
||||
continue
|
||||
}
|
||||
if (key !== AsyncIteratorSymbol && key !== IteratorSymbol) continue
|
||||
if (!Object.prototype.propertyIsEnumerable.call(source, key)) continue
|
||||
guardedSet(key, Reflect.get(source, key))
|
||||
}
|
||||
}
|
||||
return out
|
||||
@@ -134,6 +110,22 @@ const constructObject = (args: Array<unknown>, node: AstNode): unknown => {
|
||||
)
|
||||
}
|
||||
|
||||
// Tool references are not data; only Object.keys(tools) reads them, for tool names.
|
||||
const rejectTools = (name: string, args: Array<unknown>, node: AstNode): void => {
|
||||
if (!(args[0] instanceof ToolReference)) return
|
||||
throw new InterpreterRuntimeError(
|
||||
`Object.${name}(...) cannot read tool references: they are not plain data. Use Object.keys(tools) for names, or search({ query }) for signatures.`,
|
||||
node,
|
||||
"InvalidDataValue",
|
||||
)
|
||||
}
|
||||
|
||||
const objectStatic = (name: string, impl: (args: Array<unknown>, node: AstNode) => unknown) =>
|
||||
sync(`Object.${name}`, (args, node) => {
|
||||
rejectTools(name, args, node)
|
||||
return impl(args, node)
|
||||
})
|
||||
|
||||
// Object constructs identically with or without new, like JS. Only `keys` copies its result into the
|
||||
// program; `values`, `entries`, `assign`, and `fromEntries` hand back the program's own values.
|
||||
export const objectGlobal = <R>(runner: Runner<R>, toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>) =>
|
||||
@@ -147,32 +139,34 @@ export const objectGlobal = <R>(runner: Runner<R>, toolKeys: (path: ReadonlyArra
|
||||
toProgram(
|
||||
args[0] instanceof ToolReference
|
||||
? [...toolKeys(args[0].path)]
|
||||
: Object.keys(enumerableSource("Object.keys(...)", args[0], node)),
|
||||
: Object.keys(requireObject("keys", args[0], node)),
|
||||
"Object.keys result",
|
||||
),
|
||||
),
|
||||
values: sync("Object.values", (args, node) =>
|
||||
Object.values(enumerableSource("Object.values(...)", args[0], node)),
|
||||
values: objectStatic("values", (args, node) => Object.values(requireObject("values", args[0], node))),
|
||||
entries: objectStatic("entries", (args, node) =>
|
||||
Object.entries(requireObject("entries", args[0], node)).map(([key, item]) => [key, item]),
|
||||
),
|
||||
entries: sync("Object.entries", (args, node) =>
|
||||
Object.entries(enumerableSource("Object.entries(...)", args[0], node)).map(([key, item]) => [key, item]),
|
||||
),
|
||||
hasOwn: sync("Object.hasOwn", (args, node) =>
|
||||
hasOwn: objectStatic("hasOwn", (args, node) =>
|
||||
Object.hasOwn(
|
||||
enumerableSource("Object.hasOwn(...)", args[0], node),
|
||||
requireObject("hasOwn", args[0], node),
|
||||
args[1] === AsyncIteratorSymbol || args[1] === IteratorSymbol ? args[1] : String(args[1]),
|
||||
),
|
||||
),
|
||||
is: sync("Object.is", (args, node) => {
|
||||
is: objectStatic("is", (args, node) => {
|
||||
if (containsOpaqueReference(args[0]) || containsOpaqueReference(args[1])) {
|
||||
throw new InterpreterRuntimeError("Object.is requires data values.", node, "InvalidDataValue")
|
||||
}
|
||||
return Object.is(args[0], args[1])
|
||||
}),
|
||||
assign: sync("Object.assign", objectAssign),
|
||||
assign: objectStatic("assign", objectAssign),
|
||||
fromEntries: new HostFunction<R>({
|
||||
name: "Object.fromEntries",
|
||||
call: (args, node) => Effect.suspend(() => objectFromEntries(runner, args[0], node)),
|
||||
call: (args, node) =>
|
||||
Effect.suspend(() => {
|
||||
rejectTools("fromEntries", args, node)
|
||||
return objectFromEntries(runner, args[0], node)
|
||||
}),
|
||||
}),
|
||||
groupBy: groupBy(runner, "Object"),
|
||||
},
|
||||
|
||||
@@ -8,12 +8,9 @@ export const stringMethods = new Set([
|
||||
"trim",
|
||||
"trimStart",
|
||||
"trimEnd",
|
||||
"trimLeft",
|
||||
"trimRight",
|
||||
"split",
|
||||
"slice",
|
||||
"substring",
|
||||
"substr",
|
||||
"includes",
|
||||
"startsWith",
|
||||
"endsWith",
|
||||
@@ -35,8 +32,6 @@ export const stringMethods = new Set([
|
||||
"search",
|
||||
"localeCompare",
|
||||
"normalize",
|
||||
"isWellFormed",
|
||||
"toWellFormed",
|
||||
])
|
||||
|
||||
const codeUnits = (name: string, op: (...codes: Array<number>) => string) =>
|
||||
|
||||
@@ -528,7 +528,7 @@ describe("CodeMode schema flexibility", () => {
|
||||
})
|
||||
const runtime = CodeMode.make({ tools: { adapter: { call } } })
|
||||
|
||||
expect(runtime.catalog).toStrictEqual([
|
||||
expect(runtime.catalog()).toStrictEqual([
|
||||
{
|
||||
path: "adapter.call",
|
||||
description: "Call an adapter-described tool",
|
||||
@@ -611,7 +611,7 @@ describe("CodeMode schema flexibility", () => {
|
||||
})
|
||||
const runtime = CodeMode.make({ tools: { users: { lookup } } })
|
||||
|
||||
expect(runtime.catalog).toStrictEqual([
|
||||
expect(runtime.catalog()).toStrictEqual([
|
||||
{
|
||||
path: "users.lookup",
|
||||
description: "Look up a user",
|
||||
@@ -631,7 +631,7 @@ describe("CodeMode schema flexibility", () => {
|
||||
execute: () => Effect.succeed("pong"),
|
||||
})
|
||||
const runtime = CodeMode.make({ tools: { net: { ping } } })
|
||||
expect(runtime.catalog[0]?.signature).toBe("tools.net.ping(input: {\n host: string,\n}): Promise<void>")
|
||||
expect(runtime.catalog()[0]?.signature).toBe("tools.net.ping(input: {\n host: string,\n}): Promise<void>")
|
||||
|
||||
const result = await Effect.runPromise(runtime.execute(`return await tools.net.ping({ host: "example.test" })`))
|
||||
expect(result.ok).toBe(true)
|
||||
@@ -684,7 +684,7 @@ describe("CodeMode public contract", () => {
|
||||
|
||||
test("describes the catalog and keeps the search built-in registered", async () => {
|
||||
const runtime = CodeMode.make({ tools })
|
||||
expect(runtime.catalog).toStrictEqual([
|
||||
expect(runtime.catalog()).toStrictEqual([
|
||||
{
|
||||
path: "orders.lookup",
|
||||
description: "Look up an order by ID",
|
||||
@@ -726,8 +726,8 @@ describe("CodeMode public contract", () => {
|
||||
const first = CodeMode.make({ tools: { zeta: { zeta, alpha }, alpha: { zeta, alpha } } })
|
||||
const second = CodeMode.make({ tools: { alpha: { alpha, zeta }, zeta: { alpha, zeta } } })
|
||||
|
||||
expect(first.catalog).toStrictEqual(second.catalog)
|
||||
expect(first.catalog.map((tool) => tool.path)).toEqual(["alpha.alpha", "alpha.zeta", "zeta.alpha", "zeta.zeta"])
|
||||
expect(first.catalog()).toStrictEqual(second.catalog())
|
||||
expect(first.catalog().map((tool) => tool.path)).toEqual(["alpha.alpha", "alpha.zeta", "zeta.alpha", "zeta.zeta"])
|
||||
})
|
||||
|
||||
test("renders bracket notation for tool names that are not JavaScript identifiers", async () => {
|
||||
@@ -739,7 +739,7 @@ describe("CodeMode public contract", () => {
|
||||
})
|
||||
const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } })
|
||||
|
||||
expect(runtime.catalog).toStrictEqual([
|
||||
expect(runtime.catalog()).toStrictEqual([
|
||||
{
|
||||
path: "context7.resolve-library-id",
|
||||
description: "Resolve a library ID",
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
/**
|
||||
* Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75:
|
||||
* - test/built-ins/RegExp/S15.10.7_A3_T1.js
|
||||
* - test/built-ins/RegExp/S15.10.7_A3_T2.js
|
||||
* - test/built-ins/Object/S15.2.2.1_A1_T1.js
|
||||
*
|
||||
* Copyright 2009 the Sputnik authors. All rights reserved.
|
||||
* Test262 portions are governed by the BSD license in LICENSE.test262.
|
||||
*
|
||||
* Only the instance-side assertions are ported. Test262 otherwise reaches `constructor` through
|
||||
* `X.prototype.constructor`, boxed primitives (`new Object(1)`), `Function`, `isPrototypeOf`, or
|
||||
* `.call`, none of which CodeMode exposes: values have no prototype chain, so `x.constructor`
|
||||
* resolves directly to the owning built-in.
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { CodeMode } from "../src/index.js"
|
||||
|
||||
const value = async (code: string) => {
|
||||
const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} }))
|
||||
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
|
||||
return result.value
|
||||
}
|
||||
|
||||
describe("constructor Test262 parity", () => {
|
||||
test("test/built-ins/RegExp/S15.10.7_A3_T1.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const __re = /[^a]*/
|
||||
return [typeof __re, __re.constructor === RegExp, __re instanceof RegExp]
|
||||
`),
|
||||
).toEqual(["object", true, true])
|
||||
})
|
||||
|
||||
test("test/built-ins/RegExp/S15.10.7_A3_T2.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const __re = new RegExp()
|
||||
return [typeof __re, __re.constructor === RegExp, __re instanceof RegExp]
|
||||
`),
|
||||
).toEqual(["object", true, true])
|
||||
})
|
||||
|
||||
test("test/built-ins/Object/S15.2.2.1_A1_T1.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const obj = new Object()
|
||||
return [obj !== undefined, obj.constructor === Object]
|
||||
`),
|
||||
).toEqual([true, true])
|
||||
})
|
||||
|
||||
test("every built-in reports itself for its own values", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
return [
|
||||
[].constructor === Array, "".constructor === String, (1).constructor === Number, true.constructor === Boolean,
|
||||
new Date(0).constructor === Date, new Map().constructor === Map, new Set().constructor === Set,
|
||||
new URL("https://a.b/").constructor === URL, new URLSearchParams("a=1").constructor === URLSearchParams,
|
||||
Promise.resolve(1).constructor === Promise, new TypeError("x").constructor === TypeError,
|
||||
new RangeError("x").constructor === RangeError, new AggregateError([]).constructor === AggregateError,
|
||||
]
|
||||
`),
|
||||
).toEqual(Array(13).fill(true))
|
||||
})
|
||||
})
|
||||
@@ -85,16 +85,18 @@ describe("Object.keys over arrays", () => {
|
||||
expect(await value(`return Object.keys({ a: 1, b: 2 })`)).toEqual(["a", "b"])
|
||||
})
|
||||
|
||||
test("non-object inputs follow ToObject, and nullish inputs name what was received", async () => {
|
||||
expect(
|
||||
await value(`return [Object.keys("ab"), Object.entries(42), Object.keys(() => 1), Object.keys(true)]`),
|
||||
).toEqual([["0", "1"], [], [], []])
|
||||
expect(await value(`try { Object.values(null) } catch (e) { return [e.name, e.message] }`)).toEqual([
|
||||
"TypeError",
|
||||
"Object.values(...) cannot convert null to an object.",
|
||||
])
|
||||
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.")
|
||||
expect((await error(`return Object.keys(tools.github.list_issues({ value: "x" }))`)).message).toContain(
|
||||
"received an un-awaited Promise",
|
||||
"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.",
|
||||
)
|
||||
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.")
|
||||
@@ -159,21 +161,11 @@ describe("for...in", () => {
|
||||
).toEqual(["github.list_issues", "github.get_issue", "memory.search", "playwright.navigate"])
|
||||
})
|
||||
|
||||
test("non-object values enumerate like JS: strings by index, everything else nothing", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const out = []
|
||||
for (const key in "ab") out.push(key)
|
||||
for (const key in 42) out.push(key)
|
||||
for (const key in null) out.push(key)
|
||||
for (const key in undefined) out.push(key)
|
||||
for (const key in new Map([[1, 2]])) out.push(key)
|
||||
for (const key in Math) out.push(key)
|
||||
return out
|
||||
`),
|
||||
).toEqual(["0", "1"])
|
||||
expect((await error(`for (const key in tools.github.list_issues({ value: "x" })) {}`)).message).toContain(
|
||||
"un-awaited Promise",
|
||||
)
|
||||
test("unsupported values fail with a hint at for...of and Object.keys", async () => {
|
||||
for (const expression of [`"text"`, "new Map([[1, 2]])", "new Set([1])", "42", "null"]) {
|
||||
const failure = await error(`for (const key in ${expression}) {}; return "no"`)
|
||||
expect(failure.message).toContain("for...in requires a plain object, array, or tools reference")
|
||||
expect(failure.message).toContain("Use for...of for arrays/strings/Maps/Sets, or Object.keys(value)")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
/**
|
||||
* Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75:
|
||||
* - test/built-ins/Object/keys/15.2.3.14-1-1.js
|
||||
* - test/built-ins/Object/keys/15.2.3.14-1-2.js
|
||||
* - test/built-ins/Object/keys/15.2.3.14-1-3.js
|
||||
* - test/built-ins/Object/keys/15.2.3.14-1-4.js
|
||||
* - test/built-ins/Object/keys/15.2.3.14-1-5.js
|
||||
* - test/built-ins/Object/entries/primitive-strings.js
|
||||
* - test/built-ins/Object/entries/primitive-numbers.js
|
||||
* - test/built-ins/Object/entries/primitive-booleans.js
|
||||
* - test/built-ins/Object/values/primitive-strings.js
|
||||
* - test/built-ins/Object/values/primitive-numbers.js
|
||||
* - test/built-ins/Object/values/primitive-booleans.js
|
||||
* - test/built-ins/Object/hasOwn/toobject_null.js
|
||||
* - test/built-ins/Object/hasOwn/toobject_undefined.js
|
||||
* - test/built-ins/Object/hasOwn/hasown_nonexistent.js
|
||||
* - test/built-ins/Object/assign/Source-String.js
|
||||
* - test/built-ins/Object/assign/Source-Null-Undefined.js
|
||||
* - test/built-ins/Object/assign/target-Array.js
|
||||
* - test/built-ins/Object/assign/Target-Null.js
|
||||
* - test/built-ins/Object/assign/Target-Undefined.js
|
||||
* - test/built-ins/Object/assign/Target-Object.js
|
||||
* - test/built-ins/Object/assign/Override.js
|
||||
* - test/built-ins/Object/assign/ObjectOverride-sameproperty.js
|
||||
*
|
||||
* Copyright (c) 2012 Ecma International. All rights reserved.
|
||||
* Copyright (C) 2015 Jordan Harband. All rights reserved.
|
||||
* Copyright 2015 Microsoft Corporation. All rights reserved.
|
||||
* Copyright 2021 Jamie Kyle. All rights reserved.
|
||||
* Test262 portions are governed by the BSD license in LICENSE.test262.
|
||||
*
|
||||
* Boxed-primitive cases (`Object.assign("a")`, `Object.assign(1, …)`) are omitted: CodeMode has no
|
||||
* wrapper objects, so a primitive target is a TypeError rather than a boxed result. `Override.js`
|
||||
* checks `Object.keys(result).length` instead of `Object.getOwnPropertyNames`. `target-Array.js`
|
||||
* omits its named-key (`-0`, `1.5`, `4294967295`), `length`, and Proxy assertions: arrays here hold
|
||||
* only indexed elements, so those keys are a TypeError (pinned below) rather than array properties.
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { CodeMode } from "../src/index.js"
|
||||
|
||||
const value = async (code: string) => {
|
||||
const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} }))
|
||||
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
|
||||
return result.value
|
||||
}
|
||||
|
||||
const throwsTypeError = (expression: string) =>
|
||||
value(`try { ${expression}; return "no throw" } catch (error) { return error.name }`)
|
||||
|
||||
describe("Object.keys Test262 parity", () => {
|
||||
test("test/built-ins/Object/keys/15.2.3.14-1-{1,2,3}.js: primitives are coerced", async () => {
|
||||
expect(await value(`return [Object.keys(0), Object.keys(true), Object.keys("abc")]`)).toEqual([
|
||||
[],
|
||||
[],
|
||||
["0", "1", "2"],
|
||||
])
|
||||
})
|
||||
|
||||
test("test/built-ins/Object/keys/15.2.3.14-1-{4,5}.js: null and undefined throw TypeError", async () => {
|
||||
expect(await throwsTypeError(`Object.keys(null)`)).toBe("TypeError")
|
||||
expect(await throwsTypeError(`Object.keys(undefined)`)).toBe("TypeError")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Object.entries and Object.values Test262 parity", () => {
|
||||
test("test/built-ins/Object/entries/primitive-strings.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const result = Object.entries('abc')
|
||||
return [Array.isArray(result), result.length, result[0][0], result[0][1], result[1][0], result[1][1], result[2][0], result[2][1]]
|
||||
`),
|
||||
).toEqual([true, 3, "0", "a", "1", "b", "2", "c"])
|
||||
})
|
||||
|
||||
test("test/built-ins/Object/entries/primitive-numbers.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
return [0, -0, Infinity, -Infinity, NaN, Math.PI].map((number) => Object.entries(number).length)
|
||||
`),
|
||||
).toEqual([0, 0, 0, 0, 0, 0])
|
||||
})
|
||||
|
||||
test("test/built-ins/Object/entries/primitive-booleans.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const trueResult = Object.entries(true)
|
||||
const falseResult = Object.entries(false)
|
||||
return [Array.isArray(trueResult), trueResult.length, Array.isArray(falseResult), falseResult.length]
|
||||
`),
|
||||
).toEqual([true, 0, true, 0])
|
||||
})
|
||||
|
||||
test("test/built-ins/Object/values/primitive-strings.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const result = Object.values('abc')
|
||||
return [Array.isArray(result), result.length, result[0], result[1], result[2]]
|
||||
`),
|
||||
).toEqual([true, 3, "a", "b", "c"])
|
||||
})
|
||||
|
||||
test("test/built-ins/Object/values/primitive-numbers.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
return [0, -0, Infinity, -Infinity, NaN, Math.PI].map((number) => Object.values(number).length)
|
||||
`),
|
||||
).toEqual([0, 0, 0, 0, 0, 0])
|
||||
})
|
||||
|
||||
test("test/built-ins/Object/values/primitive-booleans.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const trueResult = Object.values(true)
|
||||
const falseResult = Object.values(false)
|
||||
return [Array.isArray(trueResult), trueResult.length, Array.isArray(falseResult), falseResult.length]
|
||||
`),
|
||||
).toEqual([true, 0, true, 0])
|
||||
})
|
||||
})
|
||||
|
||||
describe("Object.hasOwn Test262 parity", () => {
|
||||
test("test/built-ins/Object/hasOwn/toobject_{null,undefined}.js", async () => {
|
||||
expect(await throwsTypeError(`Object.hasOwn(null, 'foo')`)).toBe("TypeError")
|
||||
expect(await throwsTypeError(`Object.hasOwn(undefined, 'foo')`)).toBe("TypeError")
|
||||
})
|
||||
|
||||
test("test/built-ins/Object/hasOwn/hasown_nonexistent.js", async () => {
|
||||
expect(await value(`const o = {}; return Object.hasOwn(o, "foo")`)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Object.assign Test262 parity", () => {
|
||||
test("test/built-ins/Object/assign/Source-String.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const target = new Object()
|
||||
const result = Object.assign(target, "123")
|
||||
return [result[0], result[1], result[2]]
|
||||
`),
|
||||
).toEqual(["1", "2", "3"])
|
||||
})
|
||||
|
||||
test("test/built-ins/Object/assign/Source-Null-Undefined.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const target = new Object()
|
||||
const result = Object.assign(target, undefined, null)
|
||||
return result === target
|
||||
`),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("test/built-ins/Object/assign/target-Array.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const target = [7, 8, 9]
|
||||
let result = Object.assign(target, [1])
|
||||
const first = [result === target, [...result]]
|
||||
const sparseArraySource = []
|
||||
sparseArraySource[2] = 3
|
||||
result = Object.assign(target, sparseArraySource)
|
||||
const second = [result === target, [...result]]
|
||||
result = Object.assign(target, { 4: 0 })
|
||||
return [...first, ...second, result === target, result.length, result[3] === undefined, result[4]]
|
||||
`),
|
||||
).toEqual([true, [1, 8, 9], true, [1, 8, 3], true, 5, true, 0])
|
||||
})
|
||||
|
||||
test("array targets accept only array indexes (deviation from target-Array.js)", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const target = [7]
|
||||
const out = []
|
||||
for (const source of [{ length: 0 }, { x: 1 }, { "1.5": 1 }, { "-0": 1 }, { ["__proto__"]: null }]) {
|
||||
try { Object.assign(target, source) } catch (error) { out.push(error.name) }
|
||||
}
|
||||
return [out, [...target], target.length, Object.keys(target)]
|
||||
`),
|
||||
).toEqual([["TypeError", "TypeError", "TypeError", "TypeError", "TypeError"], [7], 1, ["0"]])
|
||||
})
|
||||
|
||||
test("test/built-ins/Object/assign/Target-{Null,Undefined}.js", async () => {
|
||||
expect(await throwsTypeError(`Object.assign(null, { a: 1 })`)).toBe("TypeError")
|
||||
expect(await throwsTypeError(`Object.assign(undefined, { a: 1 })`)).toBe("TypeError")
|
||||
})
|
||||
|
||||
test("test/built-ins/Object/assign/Target-Object.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const target = { foo: 1 }
|
||||
const result = Object.assign(target, { a: 2 })
|
||||
return [result.foo, result.a]
|
||||
`),
|
||||
).toEqual([1, 2])
|
||||
})
|
||||
|
||||
test("test/built-ins/Object/assign/Override.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const target = { a: 1 }
|
||||
const result = Object.assign(target, "1a2c3", { a: "c" }, undefined, { b: 6 }, null, 125, { a: 5 })
|
||||
return [Object.keys(result).length, result.a, result[0], result[1], result[2], result[3], result[4], result.b]
|
||||
`),
|
||||
).toEqual([7, 5, "1", "a", "2", "c", "3", 6])
|
||||
})
|
||||
|
||||
test("test/built-ins/Object/assign/ObjectOverride-sameproperty.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const target = { a: 1 }
|
||||
const result = Object.assign(target, { a: 2 }, { a: "c" })
|
||||
return result.a
|
||||
`),
|
||||
).toBe("c")
|
||||
})
|
||||
})
|
||||
@@ -118,12 +118,9 @@ describe("H6: object spread of null/undefined is a no-op", () => {
|
||||
expect(await value(`const o = { a: 1 }; return { ...o, b: 2 }`)).toEqual({ a: 1, b: 2 })
|
||||
})
|
||||
|
||||
test("spreading an array or string into an object copies index keys, like JS", async () => {
|
||||
expect(await value(`return { ...[1,2], a: 1 }`)).toEqual({ 0: 1, 1: 2, a: 1 })
|
||||
expect(await value(`return { ..."ab", ...5, ...true, ...(() => 1), ...new Map([[1, 2]]) }`)).toEqual({
|
||||
0: "a",
|
||||
1: "b",
|
||||
})
|
||||
test("spreading an array into an object still errors", async () => {
|
||||
const err = await error(`return { ...[1,2], a: 1 }`)
|
||||
expect(err.kind).toBe("InvalidDataValue")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -491,8 +488,12 @@ describe("CodeMode-specific string behavior", () => {
|
||||
expect(await value(`try { "x".normalize("nope"); return "no" } catch (e) { return e.message }`)).toContain('"NFC"')
|
||||
})
|
||||
|
||||
test("exposes the Annex B string aliases every engine ships", async () => {
|
||||
expect(await value(`return [" x ".trimLeft(), " x ".trimRight(), "abc".substr(1, 1)]`)).toEqual(["x ", " x", "b"])
|
||||
test("does not expose obsolete string aliases", async () => {
|
||||
expect(await value(`return [typeof "x".trimLeft, typeof "x".trimRight, typeof "x".substr]`)).toEqual([
|
||||
"undefined",
|
||||
"undefined",
|
||||
"undefined",
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -740,7 +740,7 @@ describe("JSDoc signatures in catalogs and search results", () => {
|
||||
"}",
|
||||
].join("\n")
|
||||
const signature = `tools.constrained(input: ${type}): Promise<${type}>`
|
||||
expect(runtime.catalog[0]?.signature).toBe(signature)
|
||||
expect(runtime.catalog()[0]?.signature).toBe(signature)
|
||||
const result = await Effect.runPromise(runtime.execute('return search({ query: "tools.constrained" })'))
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) throw new Error("search failed")
|
||||
@@ -796,7 +796,7 @@ describe("JSDoc signatures in catalogs and search results", () => {
|
||||
})
|
||||
|
||||
test("the catalog uses the same JSDoc signatures as search", async () => {
|
||||
const catalog = runtime.catalog
|
||||
const catalog = runtime.catalog()
|
||||
const github = (await search("list issues repository")).items.find(
|
||||
({ path }) => path === "tools.github.list_issues",
|
||||
)!
|
||||
@@ -824,7 +824,7 @@ describe("non-identifier tool paths", () => {
|
||||
const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } })
|
||||
|
||||
test("catalog signatures use bracket notation for dashed tool names", () => {
|
||||
expect(runtime.catalog[0]?.signature).toBe(
|
||||
expect(runtime.catalog()[0]?.signature).toBe(
|
||||
'tools.context7["resolve-library-id"](input: {\n query: string,\n libraryName: string,\n}): Promise<unknown>',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -722,17 +722,6 @@ describe("Set", () => {
|
||||
})
|
||||
|
||||
describe("stdlib integration", () => {
|
||||
test("constructor follows own keys, shadowing, writes, and new", async () => {
|
||||
expect(
|
||||
await value(`return [JSON.parse('{"constructor":"Foo"}').constructor, ({ constructor: 1 }).constructor]`),
|
||||
).toEqual(["Foo", 1])
|
||||
expect(await value(`const Array = 5; return [].constructor.isArray([])`)).toBe(true)
|
||||
expect(await value(`const o = {}; o.constructor = 7; return o.constructor`)).toBe(7)
|
||||
expect(await value(`return new ([].constructor)(3).length`)).toBe(3)
|
||||
expect(await value(`return typeof ({}).constructor`)).toBe("function")
|
||||
expect(await value(`return ({}).constructor.constructor`)).toBeNull()
|
||||
})
|
||||
|
||||
test("new dispatches on the constructor value, not its name", async () => {
|
||||
expect(await value(`const D = Date; return new D(0) instanceof Date`)).toBe(true)
|
||||
expect(await value(`const make = (C) => new C([["a", 1]]); return make(Map).get("a")`)).toBe(1)
|
||||
@@ -1130,7 +1119,7 @@ describe("CodeMode values at intra-CodeMode checkpoints", () => {
|
||||
const diagnostic = await error(`return Object.keys(Promise.resolve({ a: 1 }))`)
|
||||
expect(diagnostic.kind).toBe("InvalidDataValue")
|
||||
expect(diagnostic.message).toContain("await")
|
||||
expect(await value(`return Object.keys(Math)`)).toEqual([])
|
||||
expect((await error(`return Object.keys(Math)`)).kind).toBe("InvalidDataValue")
|
||||
})
|
||||
|
||||
test("Object.assign keeps Maps usable", async () => {
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
/**
|
||||
* Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75:
|
||||
* - test/annexB/built-ins/String/prototype/substr/length-falsey.js
|
||||
* - test/annexB/built-ins/String/prototype/substr/length-negative.js
|
||||
* - test/annexB/built-ins/String/prototype/substr/length-positive.js
|
||||
* - test/annexB/built-ins/String/prototype/substr/length-undef.js
|
||||
* - test/annexB/built-ins/String/prototype/substr/start-negative.js
|
||||
* - test/annexB/built-ins/String/prototype/substr/surrogate-pairs.js
|
||||
* - test/built-ins/String/prototype/isWellFormed/returns-boolean.js
|
||||
* - test/built-ins/String/prototype/toWellFormed/returns-well-formed-string.js
|
||||
* - test/built-ins/Date/prototype/toDateString/format.js
|
||||
* - test/built-ins/Date/prototype/toDateString/invalid-date.js
|
||||
* - test/built-ins/Date/prototype/toDateString/negative-year.js
|
||||
* - test/built-ins/Date/prototype/toTimeString/format.js
|
||||
* - test/built-ins/Date/prototype/toTimeString/invalid-date.js
|
||||
*
|
||||
* Copyright (C) 2016, 2017 the V8 project authors. All rights reserved.
|
||||
* Copyright (C) 2018 Richard Gibson. All rights reserved.
|
||||
* Copyright (C) 2022 Jordan Harband. All rights reserved.
|
||||
* Test262 portions are governed by the BSD license in LICENSE.test262.
|
||||
*
|
||||
* The `typeof String.prototype.method` checks are replaced with `typeof "".method` because
|
||||
* CodeMode has no prototype objects.
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { CodeMode } from "../src/index.js"
|
||||
|
||||
const value = async (code: string) => {
|
||||
const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} }))
|
||||
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
|
||||
return result.value
|
||||
}
|
||||
|
||||
describe("String.prototype.substr Test262 parity", () => {
|
||||
test("test/annexB/built-ins/String/prototype/substr/length-falsey.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
return [false, NaN, "", null].flatMap((length) => [0, 1, 2, 3].map((start) => "abc".substr(start, length)))
|
||||
`),
|
||||
).toEqual(Array(16).fill(""))
|
||||
})
|
||||
|
||||
test("test/annexB/built-ins/String/prototype/substr/length-negative.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
return [0, 1, 2, 3].flatMap((start) => [-1, -2, -3, -4].map((length) => "abc".substr(start, length)))
|
||||
`),
|
||||
).toEqual(Array(16).fill(""))
|
||||
})
|
||||
|
||||
test("test/annexB/built-ins/String/prototype/substr/length-positive.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
return [0, 1, 2, 3].map((start) => [1, 2, 3, 4].map((length) => "abc".substr(start, length)))
|
||||
`),
|
||||
).toEqual([
|
||||
["a", "ab", "abc", "abc"],
|
||||
["b", "bc", "bc", "bc"],
|
||||
["c", "c", "c", "c"],
|
||||
["", "", "", ""],
|
||||
])
|
||||
})
|
||||
|
||||
test("test/annexB/built-ins/String/prototype/substr/length-undef.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
return [
|
||||
"abc".substr(0), "abc".substr(1), "abc".substr(2), "abc".substr(3),
|
||||
"abc".substr(0, undefined), "abc".substr(1, undefined), "abc".substr(2, undefined), "abc".substr(3, undefined),
|
||||
]
|
||||
`),
|
||||
).toEqual(["abc", "bc", "c", "", "abc", "bc", "c", ""])
|
||||
})
|
||||
|
||||
test("test/annexB/built-ins/String/prototype/substr/start-negative.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
return ["abc".substr(-1), "abc".substr(-2), "abc".substr(-3), "abc".substr(-4), "abc".substr(-1.1)]
|
||||
`),
|
||||
).toEqual(["c", "bc", "abc", "abc", "c"])
|
||||
})
|
||||
|
||||
test("test/annexB/built-ins/String/prototype/substr/surrogate-pairs.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const pair = "\\ud834\\udf06"
|
||||
return [pair.substr(0), pair.substr(1), pair.substr(2), pair.substr(0, 0), pair.substr(0, 1), pair.substr(0, 2)]
|
||||
`),
|
||||
).toEqual(["\ud834\udf06", "\udf06", "", "", "\ud834", "\ud834\udf06"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("String well-formedness Test262 parity", () => {
|
||||
test("test/built-ins/String/prototype/isWellFormed/returns-boolean.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const leadingPoo = "\\uD83D"
|
||||
const trailingPoo = "\\uDCA9"
|
||||
const wholePoo = leadingPoo + trailingPoo
|
||||
return [
|
||||
typeof "".isWellFormed,
|
||||
("a" + leadingPoo + "c" + leadingPoo + "e").isWellFormed(),
|
||||
("a" + trailingPoo + "c" + trailingPoo + "e").isWellFormed(),
|
||||
("a" + trailingPoo + leadingPoo + "d").isWellFormed(),
|
||||
"a💩c".isWellFormed(),
|
||||
"a\\uD83D\\uDCA9c".isWellFormed(),
|
||||
("a" + leadingPoo + trailingPoo + "d").isWellFormed(),
|
||||
wholePoo.slice(0, 1).isWellFormed(),
|
||||
wholePoo.slice(1).isWellFormed(),
|
||||
"abc".isWellFormed(),
|
||||
"a\\u25A8c".isWellFormed(),
|
||||
]
|
||||
`),
|
||||
).toEqual(["function", false, false, false, true, true, true, false, false, true, true])
|
||||
})
|
||||
|
||||
test("test/built-ins/String/prototype/toWellFormed/returns-well-formed-string.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const replacementChar = "\\uFFFD"
|
||||
const leadingPoo = "\\uD83D"
|
||||
const trailingPoo = "\\uDCA9"
|
||||
const wholePoo = leadingPoo + trailingPoo
|
||||
return [
|
||||
typeof "".toWellFormed,
|
||||
("a" + leadingPoo + "c" + leadingPoo + "e").toWellFormed() === "a" + replacementChar + "c" + replacementChar + "e",
|
||||
("a" + trailingPoo + "c" + trailingPoo + "e").toWellFormed() === "a" + replacementChar + "c" + replacementChar + "e",
|
||||
("a" + trailingPoo + leadingPoo + "d").toWellFormed() === "a" + replacementChar + replacementChar + "d",
|
||||
"a💩c".toWellFormed() === "a💩c",
|
||||
"a\\uD83D\\uDCA9c".toWellFormed() === "a\\uD83D\\uDCA9c",
|
||||
("a" + leadingPoo + trailingPoo + "d").toWellFormed() === "a" + wholePoo + "d",
|
||||
wholePoo.slice(0, 1).toWellFormed() === replacementChar,
|
||||
wholePoo.slice(1).toWellFormed() === replacementChar,
|
||||
"abc".toWellFormed() === "abc",
|
||||
"a\\u25A8c".toWellFormed() === "a\\u25A8c",
|
||||
]
|
||||
`),
|
||||
).toEqual(["function", true, true, true, true, true, true, true, true, true, true])
|
||||
})
|
||||
})
|
||||
|
||||
describe("Date string formatting Test262 parity", () => {
|
||||
test("test/built-ins/Date/prototype/toDateString/format.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const dateRegExp = /^(Sun|Mon|Tue|Wed|Thu|Fri|Sat) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) [0-9]{2} [0-9]{4}$/
|
||||
return [dateRegExp.test(new Date(0).toDateString()), dateRegExp.test(new Date("0020-01-01T00:00:00Z").toDateString())]
|
||||
`),
|
||||
).toEqual([true, true])
|
||||
})
|
||||
|
||||
test("test/built-ins/Date/prototype/toDateString/invalid-date.js", async () => {
|
||||
expect(await value(`return new Date(NaN).toDateString()`)).toBe("Invalid Date")
|
||||
})
|
||||
|
||||
test("test/built-ins/Date/prototype/toDateString/negative-year.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
return ["-000001", "-000012", "-000123", "-001234", "-012345", "-123456"].map(
|
||||
(year) => new Date(year + "-07-01T00:00Z").toDateString().split(" ")[3],
|
||||
)
|
||||
`),
|
||||
).toEqual(["-0001", "-0012", "-0123", "-1234", "-12345", "-123456"])
|
||||
})
|
||||
|
||||
test("test/built-ins/Date/prototype/toTimeString/format.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const timeRegExp = /^[0-9]{2}:[0-9]{2}:[0-9]{2} GMT[+-][0-9]{4}( \\(.+\\))?$/
|
||||
return timeRegExp.test(new Date(0).toTimeString())
|
||||
`),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("test/built-ins/Date/prototype/toTimeString/invalid-date.js", async () => {
|
||||
expect(await value(`return new Date(NaN).toTimeString()`)).toBe("Invalid Date")
|
||||
})
|
||||
})
|
||||
@@ -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")
|
||||
})
|
||||
|
||||
@@ -185,17 +185,17 @@ describe("blocked member names on tool paths", () => {
|
||||
const array = []
|
||||
object.__proto__ = { polluted: true }
|
||||
return [
|
||||
object.constructor === Object, array.constructor === Array, "".constructor === String, Math.constructor,
|
||||
object.__proto__.polluted, ({}).polluted, array.__proto__, Object().__proto__, new Object().constructor === Object,
|
||||
({}).constructor.constructor, [].constructor.__proto__, typeof [].__proto__,
|
||||
object.constructor, array.constructor, "".constructor, Math.constructor,
|
||||
object.__proto__.polluted, ({}).polluted, array.__proto__, Object().__proto__, new Object().constructor,
|
||||
typeof ({}).constructor, typeof [].__proto__,
|
||||
]
|
||||
`,
|
||||
),
|
||||
).toEqual([true, true, true, null, true, null, null, null, true, null, null, "undefined"])
|
||||
).toEqual([null, null, null, null, true, null, null, null, null, "undefined", "undefined"])
|
||||
expect((await failure(runtime, `return (() => 1).constructor`)).message).toContain(
|
||||
"Cannot read properties of a function",
|
||||
)
|
||||
const escape = await failure(runtime, `return ({}).constructor.constructor.constructor("return 1")()`)
|
||||
const escape = await failure(runtime, `return ({}).constructor.constructor("return 1")()`)
|
||||
expect(escape.message).toContain("Cannot access a property on a non-object value")
|
||||
const poisoned = await failure(runtime, `const o = {}; o.__proto__.constructor("return 1")`)
|
||||
expect(poisoned.message).toContain("Cannot access a property on a non-object value")
|
||||
@@ -221,7 +221,7 @@ describe("namespace metadata", () => {
|
||||
const runtime = CodeMode.make({ tools })
|
||||
|
||||
test("the wrapper does not add a segment to callable paths", async () => {
|
||||
expect(runtime.catalog.map((tool) => tool.path)).toEqual(["api.status", "api.users.list", "plain.read"])
|
||||
expect(runtime.catalog().map((tool) => tool.path)).toEqual(["api.status", "api.users.list", "plain.read"])
|
||||
expect(await value(runtime, `return await tools.api.users.list({})`)).toBe("users")
|
||||
})
|
||||
|
||||
@@ -260,8 +260,8 @@ describe("canonical path collisions", () => {
|
||||
tools: { "issues.list": echo("First", "first"), issues: { list: echo("Second", "second") } },
|
||||
})
|
||||
expect(await value(runtime, `return await tools.issues.list({})`)).toBe("second")
|
||||
expect(runtime.catalog).toHaveLength(1)
|
||||
expect(runtime.catalog[0]?.description).toBe("Second")
|
||||
expect(runtime.catalog()).toHaveLength(1)
|
||||
expect(runtime.catalog()[0]?.description).toBe("Second")
|
||||
})
|
||||
|
||||
test("overriding one path keeps sibling tools from both shapes", async () => {
|
||||
@@ -272,7 +272,7 @@ describe("canonical path collisions", () => {
|
||||
"issues.close": echo("Close issue", "closed"),
|
||||
},
|
||||
})
|
||||
expect(runtime.catalog.map((tool) => tool.path)).toEqual(["issues.close", "issues.get", "issues.list"])
|
||||
expect(runtime.catalog().map((tool) => tool.path)).toEqual(["issues.close", "issues.get", "issues.list"])
|
||||
expect(await value(runtime, `return await tools.issues.list({})`)).toBe("second")
|
||||
expect(await value(runtime, `return await tools.issues.get({})`)).toBe("got")
|
||||
expect(await value(runtime, `return await tools.issues.close({})`)).toBe("closed")
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
/**
|
||||
* Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75:
|
||||
* - test/language/statements/variable/S12.2_A1.js
|
||||
* - test/language/statements/variable/S12.2_A3.js
|
||||
* - test/language/statements/variable/S12.2_A6_T1.js
|
||||
* - test/language/statements/variable/S12.2_A7.js
|
||||
* - test/language/statements/variable/S12.2_A10.js
|
||||
* - test/language/statements/variable/S12.2_A12.js
|
||||
* - test/language/block-scope/shadowing/hoisting-var-declarations-out-of-blocks.js
|
||||
* - test/language/block-scope/shadowing/catch-parameter-shadowing-var-variable.js
|
||||
* - test/language/statements/for/head-var-bound-names-in-stmt.js
|
||||
* - test/language/statements/function/scope-paramsbody-var-open.js
|
||||
* - test/language/statements/function/scope-paramsbody-var-close.js
|
||||
*
|
||||
* Copyright 2009 the Sputnik authors. All rights reserved.
|
||||
* Copyright (C) 2011, 2016 the V8 project authors. All rights reserved.
|
||||
* Test262 portions are governed by the BSD license in LICENSE.test262.
|
||||
*
|
||||
* Files that observe `var` through `eval`, `this`, `delete`, or the global object (S12.2_A2, A5, A9,
|
||||
* A11, `scope-*-none.js`, `scope-param-elem-*.js`) have no analogue here and are not ported.
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { CodeMode } from "../src/index.js"
|
||||
|
||||
const value = async (code: string) => {
|
||||
const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} }))
|
||||
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
|
||||
return result.value
|
||||
}
|
||||
|
||||
describe("var hoisting Test262 parity", () => {
|
||||
test("test/language/statements/variable/S12.2_A1.js: use before declaration reads undefined", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
__x = __x
|
||||
__y = __x ? "good fellow" : "liar"
|
||||
__z = __z === __x ? 1 : 0
|
||||
let unknown
|
||||
try { __something__undefined = __something__undefined } catch (error) { unknown = error.name }
|
||||
const before = [__y, __z, unknown]
|
||||
var __x, __y = true, __z = __y ? "smeagol" : "golum"
|
||||
return [...before, __y, __z]
|
||||
`),
|
||||
).toEqual(["liar", 1, "ReferenceError", true, "smeagol"])
|
||||
})
|
||||
|
||||
test("test/language/statements/variable/S12.2_A3.js: nested functions redeclare or assign", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
var __var = "OUT"
|
||||
const inner = (function () {
|
||||
var __var = "IN"
|
||||
;(function () { __var = "INNER_SPACE" })()
|
||||
;(function () { var __var = "INNER_SUN" })()
|
||||
return __var
|
||||
})()
|
||||
const after = __var
|
||||
const assigned = (function () {
|
||||
__var = "IN"
|
||||
;(function () { __var = "INNERED" })()
|
||||
;(function () { var __var = "INNAGER" })()
|
||||
return __var
|
||||
})()
|
||||
return [inner, after, assigned, __var]
|
||||
`),
|
||||
).toEqual(["INNER_SPACE", "OUT", "INNERED", "INNERED"])
|
||||
})
|
||||
|
||||
test("test/language/statements/variable/S12.2_A6_T1.js: var inside try and catch is hoisted", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
intry__var = intry__var
|
||||
incatch__var = incatch__var
|
||||
try { var intry__var } catch (e) { var incatch__var }
|
||||
return [typeof intry__var, typeof incatch__var]
|
||||
`),
|
||||
).toEqual(["undefined", "undefined"])
|
||||
})
|
||||
|
||||
test("test/language/statements/variable/S12.2_A7.js: var after break inside for is hoisted", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
infor_var = infor_var
|
||||
for (;;) { break; var infor_var }
|
||||
return typeof infor_var
|
||||
`),
|
||||
).toBe("undefined")
|
||||
})
|
||||
|
||||
test("test/language/statements/variable/S12.2_A10.js: var in for head is hoisted", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
__ind = __ind
|
||||
for (var __ind; ; ) { break }
|
||||
return typeof __ind
|
||||
`),
|
||||
).toBe("undefined")
|
||||
})
|
||||
|
||||
test("test/language/statements/variable/S12.2_A12.js: var in do-while body is hoisted", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
x = x
|
||||
do var x; while (false)
|
||||
return typeof x
|
||||
`),
|
||||
).toBe("undefined")
|
||||
})
|
||||
|
||||
test("test/language/block-scope/shadowing/hoisting-var-declarations-out-of-blocks.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
function fn() {
|
||||
{ var x = 1; var y }
|
||||
return [x, typeof y]
|
||||
}
|
||||
return fn()
|
||||
`),
|
||||
).toEqual([1, "undefined"])
|
||||
})
|
||||
|
||||
test("test/language/block-scope/shadowing/catch-parameter-shadowing-var-variable.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
function fn() {
|
||||
var a = 1
|
||||
let caught
|
||||
try { throw "stuff3" } catch (a) { caught = a }
|
||||
return [caught, a]
|
||||
}
|
||||
return fn()
|
||||
`),
|
||||
).toEqual(["stuff3", 1])
|
||||
})
|
||||
|
||||
test("test/language/statements/for/head-var-bound-names-in-stmt.js: redeclaring the head var in the body", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
var iterCount = 0
|
||||
var first = true
|
||||
for (var x; first; first = false) {
|
||||
var x
|
||||
iterCount += 1
|
||||
}
|
||||
return iterCount
|
||||
`),
|
||||
).toBe(1)
|
||||
})
|
||||
|
||||
test("test/language/statements/function/scope-paramsbody-var-open.js: parameter defaults see the outer var", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
var x = "outside"
|
||||
var probeParams, probeBody
|
||||
function f(_ = probeParams = function () { return x }) {
|
||||
var x = "inside"
|
||||
probeBody = function () { return x }
|
||||
}
|
||||
f()
|
||||
return [probeParams(), probeBody()]
|
||||
`),
|
||||
).toEqual(["outside", "inside"])
|
||||
})
|
||||
|
||||
test("test/language/statements/function/scope-paramsbody-var-close.js: body var does not leak out", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
var probe
|
||||
function f(_ = null) {
|
||||
var x = "inside"
|
||||
probe = function () { return x }
|
||||
}
|
||||
f()
|
||||
var x = "outside"
|
||||
return [probe(), x]
|
||||
`),
|
||||
).toEqual(["inside", "outside"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("var semantics beyond Test262", () => {
|
||||
test("redeclaration and block-level var assign the one function-scoped binding", async () => {
|
||||
expect(await value(`var a = 1; var a = 2; { var a = 3 } return a`)).toBe(3)
|
||||
expect(await value(`var q = 1; { let q = 2 } return q`)).toBe(1)
|
||||
})
|
||||
|
||||
test("var loop counters are shared by closures, let counters are not", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const byVar = []
|
||||
for (var i = 0; i < 3; i++) byVar.push(() => i)
|
||||
const byLet = []
|
||||
for (let j = 0; j < 3; j++) byLet.push(() => j)
|
||||
return [byVar.map((f) => f()), byLet.map((f) => f())]
|
||||
`),
|
||||
).toEqual([
|
||||
[3, 3, 3],
|
||||
[0, 1, 2],
|
||||
])
|
||||
})
|
||||
|
||||
test("for...in and for...of var heads survive the loop", async () => {
|
||||
expect(await value(`for (var k in { a: 1 }) {} for (var [p, q] of [[1, 2]]) {} return [k, p, q]`)).toEqual([
|
||||
"a",
|
||||
1,
|
||||
2,
|
||||
])
|
||||
})
|
||||
|
||||
test("var and function declarations of the same name share a binding", async () => {
|
||||
expect(await value(`var fn = 1; function fn() {} return typeof fn`)).toBe("number")
|
||||
expect(await value(`function fn() {} var fn; return typeof fn`)).toBe("function")
|
||||
expect(await value(`function h() { var fn = 1; function fn() {} return typeof fn } return h()`)).toBe("number")
|
||||
})
|
||||
|
||||
test("a var named after a parameter keeps the argument until assigned", async () => {
|
||||
expect(await value(`function f(a) { var a; return a } return f(7)`)).toBe(7)
|
||||
expect(await value(`function f(a) { var a = 2; return a } return f(7)`)).toBe(2)
|
||||
})
|
||||
|
||||
test("var does not hoist across function boundaries", async () => {
|
||||
expect(await value(`return [typeof b, (() => { var b = 1; return b })()]; var b`)).toEqual(["undefined", 1])
|
||||
expect(
|
||||
await value(
|
||||
`function outer() { var o = 1; function inner() { var o = 2; return o } return [inner(), o] } return outer()`,
|
||||
),
|
||||
).toEqual([2, 1])
|
||||
})
|
||||
|
||||
test("switch cases, labels, and generators hoist var", async () => {
|
||||
expect(await value(`switch (1) { case 1: var s = 9 } label: { var lb = 1 } return [s, lb]`)).toEqual([9, 1])
|
||||
expect(await value(`function* gen() { var t = 1; yield t; var t = 2; yield t } return [...gen()]`)).toEqual([1, 2])
|
||||
})
|
||||
})
|
||||
@@ -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.includes("gpt-oss")
|
||||
const chat = input.modelID === "openai.gpt-oss-safeguard-20b" || input.modelID === "openai.gpt-oss-safeguard-120b"
|
||||
return {
|
||||
package: `@opencode/ai/providers/amazon-bedrock/mantle/${chat ? "chat" : "responses"}`,
|
||||
settings: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -203,7 +203,7 @@ function variants(remote: UsableModel, messages: boolean): Model.Info["variants"
|
||||
settings: {
|
||||
thinking: {
|
||||
type: "adaptive",
|
||||
display: "summarized",
|
||||
...(remote.id.includes("opus-4.7") ? { display: "summarized" } : {}),
|
||||
},
|
||||
effort,
|
||||
},
|
||||
|
||||
@@ -54,7 +54,7 @@ function make(
|
||||
return define({
|
||||
id,
|
||||
effect: Effect.fn(`OptimizePlugin.${id}`)(function* (ctx) {
|
||||
const hook = (event: SessionHooks["context"]) =>
|
||||
yield* ctx.session.hook("context", (event) =>
|
||||
Effect.gen(function* () {
|
||||
const model =
|
||||
(yield* ctx.catalog.model.list()).data.find(
|
||||
@@ -67,10 +67,8 @@ 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))
|
||||
yield* ctx.session.hook("context", hook)
|
||||
yield* ctx.session.hook("compaction", hook)
|
||||
yield* ctx.session.hook("generate", hook)
|
||||
}).pipe(Effect.catch(() => Effect.void)),
|
||||
)
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -178,50 +178,29 @@ export const AzurePlugin = define({
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
|
||||
// Entra bearer tokens are minted per request from the target URL's scope, so they are injected at the
|
||||
// transport hooks rather than stored as a credential.
|
||||
const bearer = Effect.fn(function* (url: string) {
|
||||
const connection = yield* ctx.integration.connection.active(Provider.ID.azure)
|
||||
const credential = connection
|
||||
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
|
||||
: undefined
|
||||
if (credential?.type !== "oauth" || credential.methodID !== methodID) return
|
||||
const target = new URL(url)
|
||||
const scope =
|
||||
target.hostname.endsWith(".services.ai.azure.com") && !target.pathname.startsWith("/models")
|
||||
? foundryScope
|
||||
: cognitiveScope
|
||||
const current = yield* token(scope).pipe(Effect.orDie)
|
||||
return `Bearer ${current.access}`
|
||||
})
|
||||
yield* ctx.session.hook(
|
||||
"http.request",
|
||||
(evt) =>
|
||||
Effect.gen(function* () {
|
||||
if (evt.model.providerID !== Provider.ID.azure) return
|
||||
const authorization = yield* bearer(evt.request.url)
|
||||
if (!authorization) return
|
||||
const connection = yield* ctx.integration.connection.active(Provider.ID.azure)
|
||||
const credential = connection
|
||||
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
|
||||
: undefined
|
||||
if (credential?.type !== "oauth" || credential.methodID !== methodID) return
|
||||
const url = new URL(evt.request.url)
|
||||
const scope =
|
||||
url.hostname.endsWith(".services.ai.azure.com") && !url.pathname.startsWith("/models")
|
||||
? foundryScope
|
||||
: cognitiveScope
|
||||
const current = yield* token(scope).pipe(Effect.orDie)
|
||||
evt.request.headers.delete("api-key")
|
||||
evt.request.headers.delete("x-api-key")
|
||||
evt.request.headers.set("authorization", authorization)
|
||||
evt.request.headers.set("authorization", `Bearer ${current.access}`)
|
||||
evt.request.headers.set("user-agent", App.useragent(ctx.app))
|
||||
}),
|
||||
{ providerID: Provider.ID.azure },
|
||||
)
|
||||
yield* ctx.session.hook(
|
||||
"experimental.ws.handshake",
|
||||
(evt) =>
|
||||
Effect.gen(function* () {
|
||||
if (evt.model.providerID !== Provider.ID.azure) return
|
||||
const authorization = yield* bearer(evt.url)
|
||||
if (!authorization) return
|
||||
delete evt.headers["api-key"]
|
||||
delete evt.headers["x-api-key"]
|
||||
evt.headers.authorization = authorization
|
||||
evt.headers["user-agent"] = App.useragent(ctx.app)
|
||||
}),
|
||||
{ providerID: Provider.ID.azure },
|
||||
)
|
||||
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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"
|
||||
@@ -55,7 +54,7 @@ export const Plugin = define({
|
||||
},
|
||||
)
|
||||
|
||||
const hook = (event: SessionHooks["context"]) =>
|
||||
yield* ctx.session.hook("context", (event) =>
|
||||
Effect.gen(function* () {
|
||||
const active = sessions.get(event.sessionID)
|
||||
const settings = yield* loadSettings()
|
||||
@@ -96,9 +95,7 @@ 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)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -11,7 +11,6 @@ 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"
|
||||
@@ -403,36 +402,6 @@ 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"
|
||||
@@ -446,6 +415,7 @@ export const layer = Layer.effect(
|
||||
const compactionRequest = (
|
||||
input: ExecuteInput,
|
||||
messages: readonly SessionMessage.Info[],
|
||||
prompt: Message[],
|
||||
webSocket?: "session",
|
||||
) => {
|
||||
const context = input.context
|
||||
@@ -465,6 +435,7 @@ export const layer = Layer.effect(
|
||||
messages: [
|
||||
...transcript.messages,
|
||||
...(input.instructionUpdate ? [Message.system(input.instructionUpdate)] : []),
|
||||
...prompt,
|
||||
],
|
||||
webSocket,
|
||||
})
|
||||
@@ -484,11 +455,7 @@ export const layer = Layer.effect(
|
||||
inputID: input.inputID,
|
||||
error: { type: "provider.unsupported-operation", message },
|
||||
})
|
||||
const prepared = yield* compactionRequest(input, context.messages, "session")
|
||||
if (prepared.event.result) {
|
||||
yield* started(input, "")
|
||||
return yield* supplied(input, prepared.event.result, "")
|
||||
}
|
||||
const prepared = yield* compactionRequest(input, context.messages, [], "session")
|
||||
const request = prepared.request
|
||||
const provenance = SessionProviderContext.provenance(context.model)
|
||||
if (!provenance) return yield* reject("Provider compaction requires a stable, configured endpoint")
|
||||
@@ -601,12 +568,9 @@ 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)
|
||||
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))],
|
||||
})
|
||||
const prepared = yield* compactionRequest(input, history.messages, [
|
||||
Message.user(buildPrompt(previous !== undefined, legacy)),
|
||||
])
|
||||
// Both requests share the retry allowance; rejected output never enters the reminder request.
|
||||
const transient = SessionRunnerRetry.transient(yield* SessionRunnerRetry.policy(context.session.id), {
|
||||
agent: context.agent.id,
|
||||
@@ -614,10 +578,10 @@ export const layer = Layer.effect(
|
||||
hook: prepared.retry,
|
||||
})
|
||||
for (const request of [
|
||||
first,
|
||||
LLMRequest.update(first, {
|
||||
prepared.request,
|
||||
LLMRequest.update(prepared.request, {
|
||||
messages: [
|
||||
...first.messages,
|
||||
...prepared.request.messages,
|
||||
Message.user(
|
||||
"The previous response did not fill in the required summary template. Do not call tools. Return the summary as text using the exact section headings from the template.",
|
||||
),
|
||||
|
||||
@@ -409,7 +409,6 @@ 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,
|
||||
|
||||
@@ -11,14 +11,7 @@ import {
|
||||
SystemPart,
|
||||
} from "@opencode/ai"
|
||||
import type { StreamOptions } from "@opencode/ai/route"
|
||||
import type {
|
||||
SessionCompaction,
|
||||
SessionContext,
|
||||
SessionGenerate,
|
||||
SessionRequest,
|
||||
SessionRequestKind,
|
||||
SessionTitle,
|
||||
} from "@opencode/plugin/effect/session"
|
||||
import type { SessionContext, 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"
|
||||
@@ -185,8 +178,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<SessionCompaction>>
|
||||
readonly generate: (input: Input) => Effect.Effect<Prepared<SessionGenerate>>
|
||||
readonly compaction: (input: Input) => Effect.Effect<Prepared<SessionContext>>
|
||||
readonly generate: (input: Input) => Effect.Effect<Prepared<SessionContext>>
|
||||
readonly title: (input: Input) => Effect.Effect<Prepared<SessionTitle>>
|
||||
}
|
||||
|
||||
@@ -316,28 +309,17 @@ export const layer = Layer.effect(
|
||||
return HttpClientResponse.fromWeb(sent, after.response)
|
||||
}).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause)))))
|
||||
: undefined
|
||||
// HTTP hooks must observe every request, so they keep the provider on HTTP.
|
||||
const webSocket =
|
||||
input.webSocket === "session" && model.capabilities.responsesWebsockets === true && model.websocket
|
||||
const interceptor: SessionModelTransport.Interceptor = {
|
||||
handshake: (connect) =>
|
||||
hooks.trigger("session", "experimental.ws.handshake", {
|
||||
...scope,
|
||||
url: connect.url,
|
||||
headers: connect.headers,
|
||||
}),
|
||||
send: (frame, mode) =>
|
||||
hooks.trigger("session", "experimental.ws.send", { ...scope, mode, frame }).pipe(Effect.map((e) => e.frame)),
|
||||
receive: (frame) =>
|
||||
hooks.trigger("session", "experimental.ws.receive", { ...scope, frame }).pipe(Effect.map((e) => e.frame)),
|
||||
}
|
||||
input.webSocket === "session" &&
|
||||
!hasHttpHooks &&
|
||||
model.capabilities.responsesWebsockets === true &&
|
||||
model.websocket
|
||||
|
||||
return {
|
||||
event: shaped,
|
||||
request,
|
||||
options: {
|
||||
...(http ? { http } : {}),
|
||||
...(webSocket ? { webSocket: transport.bind(session.id, interceptor) } : {}),
|
||||
},
|
||||
options: { ...(http ? { http } : {}), ...(webSocket ? { webSocket: transport.bind(session.id) } : {}) },
|
||||
retry: (event: Parameters<Prepared["retry"]>[0]) =>
|
||||
hooks.trigger("session", "retry", event).pipe(Effect.asVoid),
|
||||
// Permission.assert and the question tool throw declines as defects so tools cannot
|
||||
@@ -360,14 +342,13 @@ export const layer = Layer.effect(
|
||||
}
|
||||
})
|
||||
|
||||
const agentHook =
|
||||
(name: "context" | "compaction" | "generate", agent: Agent.ID) => (draft: SessionRequest, tools: Definitions) =>
|
||||
hooks.trigger("session", name, { ...draft, agent, tools })
|
||||
const context = (agent: Agent.ID) => (draft: SessionRequest, tools: Definitions) =>
|
||||
hooks.trigger("session", "context", { ...draft, agent, tools })
|
||||
|
||||
return Service.of({
|
||||
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)),
|
||||
primary: (input) => prepare("primary", input, context(input.agent)),
|
||||
generate: (input) => prepare("generate", input, context(input.agent)),
|
||||
compaction: (input) => prepare("compaction", input, context(input.agent)),
|
||||
title: (input) => prepare("title", input, (draft) => hooks.trigger("session", "title", draft)),
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -2,7 +2,6 @@ export * as SessionModelTransport from "./model-transport.js"
|
||||
|
||||
import {
|
||||
WebSocketTransport,
|
||||
type ChannelCreate,
|
||||
type ChannelObservation,
|
||||
type ChannelCheckpoint,
|
||||
type WebSocketChannelExchange,
|
||||
@@ -14,7 +13,6 @@ import {
|
||||
import { AIError, AIErrorReason, TransportError, type TransportOperation } from "@opencode/ai"
|
||||
import { Hash } from "@opencode/util/hash"
|
||||
import { Cause, Clock, Context, Effect, Fiber, Layer, Metric, Queue, Scope, Semaphore, Stream } from "effect"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { Socket } from "effect/unstable/socket"
|
||||
import { makeGlobalNode } from "@opencode/util/effect/app-node"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
@@ -54,18 +52,8 @@ interface State {
|
||||
channel?: Channel
|
||||
}
|
||||
|
||||
/** Per-exchange plugin hooks. `handshake` output selects the connection; frames are what crosses the wire. */
|
||||
export interface Interceptor {
|
||||
readonly handshake: (connect: {
|
||||
readonly url: string
|
||||
readonly headers: Record<string, string>
|
||||
}) => Effect.Effect<{ readonly url: string; readonly headers: Record<string, string> }>
|
||||
readonly send: (frame: string, mode: ChannelCreate["mode"]) => Effect.Effect<string>
|
||||
readonly receive: (frame: string) => Effect.Effect<string>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly bind: (sessionID: SessionSchema.ID, interceptor?: Interceptor) => WebSocketChannelExecutor
|
||||
readonly bind: (sessionID: SessionSchema.ID) => WebSocketChannelExecutor
|
||||
readonly close: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
readonly closeAll: Effect.Effect<void>
|
||||
}
|
||||
@@ -279,8 +267,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
|
||||
const start = Effect.fn("SessionModelTransport.start")(function* (
|
||||
owner: State,
|
||||
input: WebSocketChannelExchange,
|
||||
interceptor?: Interceptor,
|
||||
exchange: WebSocketChannelExchange,
|
||||
) {
|
||||
if (owner.closed)
|
||||
return yield* transportError("Session WebSocket owner is closed", {
|
||||
@@ -289,16 +276,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
phase: "queue",
|
||||
delivery: "not-sent",
|
||||
})
|
||||
if (owner.httpFallback) return fallback(input)
|
||||
const handshake = interceptor
|
||||
? yield* interceptor.handshake({ url: input.connect.url, headers: { ...input.connect.headers } })
|
||||
: undefined
|
||||
const exchange: WebSocketChannelExchange = handshake
|
||||
? {
|
||||
...input,
|
||||
connect: { ...input.connect, url: handshake.url, headers: Headers.fromInput(handshake.headers) },
|
||||
}
|
||||
: input
|
||||
if (owner.httpFallback) return fallback(exchange)
|
||||
const key = affinity(exchange)
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
const current = owner.channel
|
||||
@@ -359,7 +337,6 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
Effect.onInterrupt(() => closeChannel(owner, channel)),
|
||||
)
|
||||
if (create.mode === "full") channel.checkpoint = undefined
|
||||
const message = interceptor ? yield* interceptor.send(create.message, create.mode) : create.message
|
||||
yield* Effect.logDebug("session websocket sending", {
|
||||
sessionTransport: "websocket",
|
||||
phase: "send",
|
||||
@@ -370,7 +347,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
delivery: "send-attempted",
|
||||
}
|
||||
channel.active = active
|
||||
const sent = yield* channel.connection.sendText(message).pipe(
|
||||
const sent = yield* channel.connection.sendText(create.message).pipe(
|
||||
Effect.withSpan("SessionModelTransport.send"),
|
||||
Effect.onInterrupt(() => closeChannel(owner, channel)),
|
||||
Effect.result,
|
||||
@@ -411,7 +388,6 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
}),
|
||||
),
|
||||
}),
|
||||
Stream.mapEffect((frame) => (interceptor ? interceptor.receive(frame) : Effect.succeed(frame))),
|
||||
Stream.mapEffect((frame) => exchange.driver.observe(create, frame)),
|
||||
Stream.tap((observation) =>
|
||||
Effect.sync(() => {
|
||||
@@ -489,7 +465,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
return { frames, complete, http: channel.connection.http }
|
||||
})
|
||||
|
||||
const bind = (sessionID: SessionSchema.ID, interceptor?: Interceptor): WebSocketChannelExecutor => ({
|
||||
const bind = (sessionID: SessionSchema.ID): WebSocketChannelExecutor => ({
|
||||
execute: (exchange) => {
|
||||
const owner = state(sessionID)
|
||||
let execution: WebSocketChannelExecution | undefined
|
||||
@@ -499,7 +475,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
},
|
||||
frames: Stream.unwrap(
|
||||
Effect.acquireRelease(owner.lock.take(1), () => owner.lock.release(1), { interruptible: true }).pipe(
|
||||
Effect.andThen(start(owner, exchange, interceptor)),
|
||||
Effect.andThen(start(owner, exchange)),
|
||||
Effect.tap((started) =>
|
||||
Effect.sync(() => {
|
||||
execution = started
|
||||
|
||||
@@ -43,7 +43,6 @@ export type MessagesInput = {
|
||||
sessionID: Session.ID
|
||||
limit?: number
|
||||
order?: "asc" | "desc"
|
||||
type?: SessionMessage.Type
|
||||
cursor?: {
|
||||
id: SessionMessage.ID
|
||||
direction: "previous" | "next"
|
||||
@@ -157,16 +156,13 @@ const layer = Layer.effect(
|
||||
? gt(SessionMessageTable.seq, anchor.seq)
|
||||
: lt(SessionMessageTable.seq, anchor.seq)
|
||||
: undefined
|
||||
const where = boundary
|
||||
? and(eq(SessionMessageTable.session_id, input.sessionID), boundary)
|
||||
: eq(SessionMessageTable.session_id, input.sessionID)
|
||||
const query = db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, input.sessionID),
|
||||
boundary,
|
||||
input.type === undefined ? undefined : eq(SessionMessageTable.type, input.type),
|
||||
),
|
||||
)
|
||||
.where(where)
|
||||
.orderBy(order === "asc" ? asc(SessionMessageTable.seq) : desc(SessionMessageTable.seq))
|
||||
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
|
||||
Effect.orDie,
|
||||
|
||||
@@ -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/chat",
|
||||
package: "@opencode/ai/providers/amazon-bedrock/mantle/responses",
|
||||
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/chat",
|
||||
package: "@opencode/ai/providers/amazon-bedrock/mantle/responses",
|
||||
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/chat",
|
||||
package: "@opencode/ai/providers/amazon-bedrock/mantle/responses",
|
||||
settings: {
|
||||
credentials: {
|
||||
accessKeyId: "key",
|
||||
|
||||
@@ -169,19 +169,19 @@ describe("ModelResolver", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("maps Bedrock Mantle GPT-OSS models to Chat and other models to Responses", () =>
|
||||
it.effect("maps Bedrock Mantle models to native Responses and safeguards to Chat", () =>
|
||||
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-5.5",
|
||||
modelID: "openai.gpt-oss-120b",
|
||||
settings: { region: "us-east-2" },
|
||||
}),
|
||||
credential,
|
||||
)
|
||||
const chat = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"), {
|
||||
modelID: "openai.gpt-oss-20b",
|
||||
modelID: "openai.gpt-oss-safeguard-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/chat",
|
||||
"@opencode/ai/providers/amazon-bedrock/mantle/responses",
|
||||
"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-chat")
|
||||
expect(mantle.route.id).toBe("bedrock-mantle-responses")
|
||||
expect(mantle.route.defaults.generation).toEqual({ topP: 0.6 })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -303,20 +303,6 @@ describe("AzurePlugin", () => {
|
||||
})
|
||||
expect(foundry.request.headers.get("authorization")).toBe("Bearer https://ai.azure.com/.default-token")
|
||||
expect(foundry.request.headers.has("x-api-key")).toBe(false)
|
||||
|
||||
const handshake = yield* hooks.trigger("session", "experimental.ws.handshake", {
|
||||
sessionID: Session.ID.make("ses_azure_ws"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model,
|
||||
kind: "primary",
|
||||
url: "wss://test-resource.openai.azure.com/openai/v1/responses",
|
||||
headers: { "api-key": "stored-token", "x-keep": "yes" },
|
||||
})
|
||||
expect(handshake.headers).toMatchObject({
|
||||
authorization: "Bearer https://cognitiveservices.azure.com/.default-token",
|
||||
"x-keep": "yes",
|
||||
})
|
||||
expect(handshake.headers["api-key"]).toBeUndefined()
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -11,7 +11,6 @@ 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"
|
||||
@@ -86,7 +85,6 @@ const it = testEffect(
|
||||
SessionStore.node,
|
||||
SessionCompaction.node,
|
||||
SessionModelRequest.node,
|
||||
PluginHooks.node,
|
||||
]),
|
||||
[Bus.node.replace(Bus.configured({ persist: true })), llmClient.replace(client)],
|
||||
),
|
||||
@@ -358,14 +356,6 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||
}
|
||||
const session = yield* insertSession(sessionID, { parent_id: parentID })
|
||||
const modelRequests = yield* SessionModelRequest.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
let hooked = 0
|
||||
yield* hooks.register("session", "compaction", (event) =>
|
||||
Effect.sync(() => {
|
||||
hooked = event.messages.length
|
||||
expect(JSON.stringify(event.messages)).not.toContain("Summarize only what")
|
||||
}),
|
||||
)
|
||||
const messages = [
|
||||
userMessage,
|
||||
SessionMessage.Shell.make({
|
||||
@@ -418,8 +408,6 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||
expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.")
|
||||
expect(JSON.stringify(requests[0]?.messages)).toContain("Use Effect services and generators.")
|
||||
expect(JSON.stringify(requests[0]?.messages)).toContain("User shell pwd completed: /project")
|
||||
expect(requests[0]?.messages).toHaveLength(hooked + 1)
|
||||
expect(JSON.stringify(requests[0]?.messages.at(-1))).toContain("Summarize only what")
|
||||
expect(JSON.stringify(requests[0]?.messages)).not.toContain("display-only-output")
|
||||
// The compaction message carries its own request usage so clients can show what compacting cost.
|
||||
expect(yield* store.context(sessionID)).toMatchObject([
|
||||
@@ -452,65 +440,6 @@ 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 = []
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
import { expect } from "bun:test"
|
||||
import {
|
||||
LLMClient,
|
||||
LLMEvent,
|
||||
LLMResponse,
|
||||
LanguageModel,
|
||||
Message,
|
||||
SystemPart,
|
||||
ToolDefinition,
|
||||
type LLMRequest,
|
||||
} from "@opencode/ai"
|
||||
import { LLMClient, LLMEvent, LLMResponse, LanguageModel, 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"
|
||||
@@ -47,7 +38,6 @@ 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"
|
||||
@@ -144,7 +134,6 @@ const it = testEffect(
|
||||
Agent.node,
|
||||
InstructionBuiltIns.node,
|
||||
SessionContext.node,
|
||||
PluginHooks.node,
|
||||
llmClient,
|
||||
]),
|
||||
[
|
||||
@@ -355,43 +344,6 @@ 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",
|
||||
() =>
|
||||
|
||||
@@ -79,68 +79,4 @@ describe("SessionModelRequest HTTP hooks", () => {
|
||||
)
|
||||
}).pipe(Effect.provideService(SessionModelTransport.Service, transport)),
|
||||
)
|
||||
|
||||
it.effect("runs experimental.ws hooks through the transport interceptor alongside http hooks", () =>
|
||||
Effect.gen(function* () {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const seen: Array<string> = []
|
||||
yield* hooks.register("session", "http.request", () => Effect.void)
|
||||
yield* hooks.register("session", "experimental.ws.handshake", (event) =>
|
||||
Effect.sync(() => {
|
||||
seen.push(`handshake:${event.kind}:${event.agent}`)
|
||||
event.url = `${event.url}?hooked`
|
||||
event.headers.authorization = "Bearer hooked"
|
||||
}),
|
||||
)
|
||||
yield* hooks.register("session", "experimental.ws.send", (event) =>
|
||||
Effect.sync(() => {
|
||||
seen.push(`send:${event.kind}:${event.mode}`)
|
||||
event.frame = `${event.frame}:sent`
|
||||
}),
|
||||
)
|
||||
yield* hooks.register("session", "experimental.ws.receive", (event) =>
|
||||
Effect.sync(() => {
|
||||
seen.push(`receive:${event.kind}`)
|
||||
event.frame = `${event.frame}:received`
|
||||
}),
|
||||
)
|
||||
let interceptor: SessionModelTransport.Interceptor | undefined
|
||||
const capturing = SessionModelTransport.Service.of({
|
||||
bind: (_sessionID, bound) => {
|
||||
interceptor = bound
|
||||
return { execute: () => Effect.die("unused WebSocket execution") }
|
||||
},
|
||||
close: () => Effect.void,
|
||||
closeAll: Effect.void,
|
||||
})
|
||||
const requests = yield* SessionModelRequest.Service.pipe(
|
||||
Effect.provide(SessionModelRequest.layer),
|
||||
Effect.provideService(SessionModelTransport.Service, capturing),
|
||||
)
|
||||
const prepared = yield* requests.compaction({
|
||||
session,
|
||||
agent: Agent.ID.make("build"),
|
||||
model: SessionRunnerModel.resolved(OpenAIChat.route.model({ id: "gpt-5.5", provider: "test" }), {
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"], responsesWebsockets: true },
|
||||
cost: [],
|
||||
limit: { context: 200_000, output: 32_000 },
|
||||
websocket: true,
|
||||
}),
|
||||
system: [],
|
||||
messages: [],
|
||||
webSocket: "session",
|
||||
})
|
||||
expect(prepared.options.http).toBeDefined()
|
||||
expect(prepared.options.webSocket).toBeDefined()
|
||||
if (!interceptor) throw new Error("Expected the transport to receive an interceptor")
|
||||
|
||||
expect(yield* interceptor.handshake({ url: "wss://example.test/v1/responses", headers: {} })).toMatchObject({
|
||||
url: "wss://example.test/v1/responses?hooked",
|
||||
headers: { authorization: "Bearer hooked" },
|
||||
})
|
||||
expect(yield* interceptor.send("frame", "incremental")).toBe("frame:sent")
|
||||
expect(yield* interceptor.receive("frame")).toBe("frame:received")
|
||||
expect(seen).toEqual(["handshake:compaction:build", "send:compaction:incremental", "receive:compaction"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -822,36 +822,6 @@ describe("SessionModelTransport", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("runs interceptors on the handshake and both frame directions", async () => {
|
||||
const fixture = automatic()
|
||||
const seen: Array<string> = []
|
||||
let authorization = "one"
|
||||
|
||||
await run(
|
||||
fixture.connector,
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const executor = transport.bind(session, {
|
||||
handshake: (connect) =>
|
||||
Effect.succeed({ url: `${connect.url}?hooked`, headers: { ...connect.headers, authorization } }),
|
||||
send: (frame, mode) => Effect.succeed(`${frame}:${mode}`),
|
||||
receive: (frame) =>
|
||||
Effect.sync(() => {
|
||||
seen.push(frame)
|
||||
return frame.toUpperCase()
|
||||
}),
|
||||
})
|
||||
expect(yield* collect(executor, exchange("first"))).toEqual(["COMPLETED:FIRST:FULL"])
|
||||
authorization = "two"
|
||||
expect(yield* collect(executor, exchange("second"))).toEqual(["COMPLETED:SECOND:FULL"])
|
||||
expect(seen).toEqual(["completed:first:full", "completed:second:full"])
|
||||
expect(fixture.connections).toHaveLength(2)
|
||||
expect(fixture.connections.map((item) => item.headers.authorization)).toEqual(["one", "two"])
|
||||
expect(fixture.connections.map((item) => item.sent)).toEqual([["first:full"], ["second:full"]])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("rotates when the connection exceeds its requested age limit", async () => {
|
||||
const fixture = automatic()
|
||||
|
||||
|
||||
@@ -400,34 +400,6 @@ 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()
|
||||
|
||||
@@ -20,7 +20,6 @@ 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"
|
||||
@@ -2412,16 +2411,15 @@ describe("SessionRunnerLLM", () => {
|
||||
model: { id: ID.make(s.currentModel.id), providerID: Provider.ID.make(s.currentModel.provider), variant },
|
||||
})
|
||||
const requestAgents: Agent.ID[] = []
|
||||
const hook = (event: SessionHooks["context"]) =>
|
||||
yield* hooks.register("session", "context", (event) =>
|
||||
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)
|
||||
|
||||
@@ -7,7 +7,6 @@ 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"
|
||||
|
||||
@@ -35,20 +34,6 @@ 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
|
||||
@@ -86,34 +71,6 @@ export interface SessionHttpResponse {
|
||||
response: Response
|
||||
}
|
||||
|
||||
/** Connection a WebSocket request needs. Changing `url` or `headers` reopens the Session's socket. */
|
||||
export interface SessionWebSocketHandshake {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
readonly kind: SessionRequestKind
|
||||
url: string
|
||||
headers: Record<string, string>
|
||||
}
|
||||
|
||||
export interface SessionWebSocketSend {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
readonly kind: SessionRequestKind
|
||||
/** Incremental frames carry only what changed since the provider's last checkpoint. */
|
||||
readonly mode: "full" | "incremental"
|
||||
frame: string
|
||||
}
|
||||
|
||||
export interface SessionWebSocketReceive {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
readonly kind: SessionRequestKind
|
||||
frame: string
|
||||
}
|
||||
|
||||
export type SessionRetryDecision = { retry: false } | { retry: true; delay: number }
|
||||
|
||||
export interface SessionRetry {
|
||||
@@ -128,15 +85,10 @@ 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
|
||||
readonly "http.response": SessionHttpResponse
|
||||
readonly "experimental.ws.handshake": SessionWebSocketHandshake
|
||||
readonly "experimental.ws.send": SessionWebSocketSend
|
||||
readonly "experimental.ws.receive": SessionWebSocketReceive
|
||||
readonly retry: SessionRetry
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ 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"
|
||||
|
||||
@@ -35,20 +34,6 @@ 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
|
||||
@@ -86,34 +71,6 @@ export interface SessionHttpResponse {
|
||||
response: Response
|
||||
}
|
||||
|
||||
/** Connection a WebSocket request needs. Changing `url` or `headers` reopens the Session's socket. */
|
||||
export interface SessionWebSocketHandshake {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
readonly kind: SessionRequestKind
|
||||
url: string
|
||||
headers: Record<string, string>
|
||||
}
|
||||
|
||||
export interface SessionWebSocketSend {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
readonly kind: SessionRequestKind
|
||||
/** Incremental frames carry only what changed since the provider's last checkpoint. */
|
||||
readonly mode: "full" | "incremental"
|
||||
frame: string
|
||||
}
|
||||
|
||||
export interface SessionWebSocketReceive {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
readonly kind: SessionRequestKind
|
||||
frame: string
|
||||
}
|
||||
|
||||
export type SessionRetryDecision = { retry: false } | { retry: true; delay: number }
|
||||
|
||||
export interface SessionRetry {
|
||||
@@ -128,15 +85,10 @@ 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
|
||||
readonly "http.response": SessionHttpResponse
|
||||
readonly "experimental.ws.handshake": SessionWebSocketHandshake
|
||||
readonly "experimental.ws.send": SessionWebSocketSend
|
||||
readonly "experimental.ws.receive": SessionWebSocketReceive
|
||||
readonly retry: SessionRetry
|
||||
}
|
||||
|
||||
|
||||
@@ -4490,34 +4490,6 @@
|
||||
]
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "type",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"agent-switched",
|
||||
"model-switched",
|
||||
"location-switched",
|
||||
"user",
|
||||
"synthetic",
|
||||
"system",
|
||||
"skill",
|
||||
"shell",
|
||||
"assistant",
|
||||
"compaction"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Filter by message type before pagination. When omitted, all message types are returned. Pass the same type when following cursors."
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
@@ -4580,7 +4552,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Retrieve projected messages for a session, optionally filtered by type. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline, passing the same type filter on each page.",
|
||||
"description": "Retrieve projected messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.",
|
||||
"summary": "Get session messages"
|
||||
}
|
||||
},
|
||||
@@ -14271,9 +14243,6 @@
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"websocket": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"modelID": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -14382,9 +14351,6 @@
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"websocket": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"canonical": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -16331,9 +16297,6 @@
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"websocket": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"settings": {
|
||||
"type": "object"
|
||||
},
|
||||
@@ -17322,9 +17285,6 @@
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"websocket": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"settings": {
|
||||
"type": "object"
|
||||
},
|
||||
@@ -18296,12 +18256,6 @@
|
||||
},
|
||||
"providerContext": {
|
||||
"$ref": "#/components/schemas/Session.ProviderContext"
|
||||
},
|
||||
"cost": {
|
||||
"$ref": "#/components/schemas/Money.USD"
|
||||
},
|
||||
"tokens": {
|
||||
"$ref": "#/components/schemas/TokenUsage.Info"
|
||||
}
|
||||
},
|
||||
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
|
||||
@@ -18341,12 +18295,6 @@
|
||||
},
|
||||
"error": {
|
||||
"$ref": "#/components/schemas/Session.StructuredError"
|
||||
},
|
||||
"cost": {
|
||||
"$ref": "#/components/schemas/Money.USD"
|
||||
},
|
||||
"tokens": {
|
||||
"$ref": "#/components/schemas/TokenUsage.Info"
|
||||
}
|
||||
},
|
||||
"required": ["type", "id", "time", "status", "reason", "error"],
|
||||
|
||||
@@ -19,23 +19,6 @@ export const SessionMessagesQuery = Schema.Struct({
|
||||
"Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.",
|
||||
}),
|
||||
),
|
||||
type: Schema.optional(
|
||||
Schema.Literals([
|
||||
"agent-switched",
|
||||
"model-switched",
|
||||
"location-switched",
|
||||
"user",
|
||||
"synthetic",
|
||||
"system",
|
||||
"skill",
|
||||
"shell",
|
||||
"assistant",
|
||||
"compaction",
|
||||
] satisfies ReadonlyArray<SessionMessage.Type>),
|
||||
).annotate({
|
||||
description:
|
||||
"Filter by message type before pagination. When omitted, all message types are returned. Pass the same type when following cursors.",
|
||||
}),
|
||||
}).annotate({ identifier: "SessionMessagesQuery" })
|
||||
|
||||
export const MessageGroup = HttpApiGroup.make("server.message")
|
||||
@@ -56,7 +39,7 @@ export const MessageGroup = HttpApiGroup.make("server.message")
|
||||
identifier: "v2.message.list",
|
||||
summary: "Get session messages",
|
||||
description:
|
||||
"Retrieve projected messages for a session, optionally filtered by type. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline, passing the same type filter on each page.",
|
||||
"Retrieve projected messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -44,7 +44,6 @@ export const MessageHandler = HttpApiBuilder.group(Api, "server.message", (handl
|
||||
sessionID: ctx.params.sessionID,
|
||||
limit: ctx.query.limit ?? DefaultMessagesLimit,
|
||||
order,
|
||||
type: ctx.query.type,
|
||||
cursor: decoded ? { id: decoded.id, direction: decoded.direction } : undefined,
|
||||
})
|
||||
.pipe(
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { OpenCode, type SessionMessageInfo } from "@opencode/client"
|
||||
import { Session } from "@opencode/schema/session"
|
||||
import { Effect } from "effect"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { ServerFetch } from "../src/fetch"
|
||||
|
||||
const messages: SessionMessageInfo[] = [
|
||||
{ id: "msg_z", type: "user", text: "First request", time: { created: 300 } },
|
||||
{
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "test" },
|
||||
content: [{ type: "text", text: "First answer" }],
|
||||
finish: "stop",
|
||||
time: { created: 400, completed: 500 },
|
||||
},
|
||||
{ id: "msg_b", type: "user", text: "Second request", time: { created: 100 } },
|
||||
{ id: "msg_synthetic", type: "synthetic", text: "Background completion", time: { created: 200 } },
|
||||
{
|
||||
id: "msg_compaction",
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
reason: "manual",
|
||||
summary: "Conversation summary",
|
||||
recent: "",
|
||||
time: { created: 700 },
|
||||
},
|
||||
{ id: "msg_x", type: "user", text: "Third request", time: { created: 600 } },
|
||||
{ id: "msg_system", type: "system", text: "Updated instructions", time: { created: 800 } },
|
||||
{ id: "msg_a", type: "user", text: "Fourth request", time: { created: 500 } },
|
||||
]
|
||||
|
||||
const setup = Effect.gen(function* () {
|
||||
const handler = yield* ServerFetch.make({
|
||||
app: { version: "test" },
|
||||
database: { path: ":memory:" },
|
||||
config: { project: false },
|
||||
models: { fetch: false },
|
||||
fs: { filewatcher: false },
|
||||
})
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://opencode.local",
|
||||
fetch: Object.assign((input: string | URL | Request, init?: RequestInit) => handler(new Request(input, init)), {
|
||||
preconnect: fetch.preconnect,
|
||||
}),
|
||||
})
|
||||
const session = yield* Effect.promise(async () => {
|
||||
const template = await api.session.create({ title: "Message filtering" })
|
||||
return api.session.import({ info: { ...template, id: Session.ID.create() }, messages })
|
||||
})
|
||||
return { api, handler, sessionID: session.id }
|
||||
})
|
||||
|
||||
it.live("filters message types before paginating in either direction through the generated client", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* setup
|
||||
yield* Effect.promise(async () => {
|
||||
const input = { sessionID: fixture.sessionID, type: "user", limit: 2 } as const
|
||||
// Omission retains the full transcript, in durable sequence rather than timestamp or ID order.
|
||||
expect(
|
||||
(await fixture.api.message.list({ sessionID: fixture.sessionID })).data.map((message) => message.id),
|
||||
).toEqual(messages.toReversed().map((message) => message.id))
|
||||
for (const order of ["asc", "desc"] as const) {
|
||||
const ids = order === "asc" ? ["msg_z", "msg_b", "msg_x", "msg_a"] : ["msg_a", "msg_x", "msg_b", "msg_z"]
|
||||
const first = await fixture.api.message.list({ ...input, order })
|
||||
expect(first.data.map((message) => message.id)).toEqual(ids.slice(0, 2))
|
||||
if (!first.cursor.next) throw new Error("Expected a next cursor")
|
||||
const second = await fixture.api.message.list({ ...input, cursor: first.cursor.next })
|
||||
expect(second.data.map((message) => message.id)).toEqual(ids.slice(2))
|
||||
if (!second.cursor.previous || !second.cursor.next) throw new Error("Expected previous and next cursors")
|
||||
const previous = await fixture.api.message.list({ ...input, cursor: second.cursor.previous })
|
||||
expect(previous.data).toEqual(first.data)
|
||||
const end = await fixture.api.message.list({ ...input, cursor: second.cursor.next })
|
||||
expect(end).toEqual({ data: [], cursor: { previous: null, next: null } })
|
||||
}
|
||||
expect((await fixture.api.message.list({ sessionID: fixture.sessionID, type: "compaction" })).data).toEqual([
|
||||
messages[4],
|
||||
])
|
||||
expect(
|
||||
(await fixture.api.message.list({ sessionID: fixture.sessionID, type: "assistant", limit: 1 })).data,
|
||||
).toEqual([messages[1]])
|
||||
expect((await fixture.api.message.list({ sessionID: fixture.sessionID, type: "shell" })).data).toEqual([])
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("rejects unknown message type filters at the HTTP boundary", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* setup
|
||||
yield* Effect.promise(async () => {
|
||||
for (const type of ["unknown", "tool", "User", ""]) {
|
||||
const response = await fixture.handler(
|
||||
new Request(`http://opencode.local/api/session/${fixture.sessionID}/message?type=${type}`),
|
||||
)
|
||||
expect(response.status).toBe(400)
|
||||
expect(await response.json()).toMatchObject({ _tag: "InvalidRequestError" })
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -13,7 +13,6 @@ 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"
|
||||
@@ -93,12 +92,11 @@ it.live(
|
||||
event.prompt.text += ` [${config.tool}]`
|
||||
}),
|
||||
)
|
||||
const tune = (event: SessionHooks["context"]) =>
|
||||
yield* ctx.session.hook("context", (event) =>
|
||||
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"
|
||||
|
||||
@@ -43,13 +43,14 @@ story("merges follow-up patches into one stack with a distinct file count", asyn
|
||||
await root.getByRole("button", { name: "Start follow-up patch" }).click()
|
||||
const usage = group.locator('[data-component="context-tool-group-trigger"] [data-slot="context-tool-group-usage"]')
|
||||
await expect(usage.locator('[data-slot="context-tool-group-prefix"]')).toHaveText("Used")
|
||||
await expect(usage.locator('[data-slot="context-tool-group-count"]')).toHaveText("3")
|
||||
await expect(usage.locator('[data-slot="context-tool-group-count"]')).toHaveText("2")
|
||||
await expect(patches).toHaveCount(1)
|
||||
await expect(patches.getByText("2 files", { exact: true })).toBeVisible()
|
||||
await root.getByRole("button", { name: "Finish follow-up patch" }).click()
|
||||
await expect(usage.locator('[data-slot="context-tool-group-count"]')).toHaveText("4")
|
||||
await expect(patches).toHaveCount(1)
|
||||
await expect(patches.getByText("3 files", { exact: true })).toBeVisible()
|
||||
await expect(patches.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts"])
|
||||
await expect(patches.getByText("4 files", { exact: true })).toBeVisible()
|
||||
await expect(patches.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts", "d.ts"])
|
||||
await expect(first).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(patches.locator('[data-component="file"]')).toBeVisible()
|
||||
await group.screenshot({ path: info.outputPath("merged.png") })
|
||||
@@ -61,7 +62,13 @@ for (const separator of ["shell", "error", "reasoning"]) {
|
||||
await root.getByRole("button", { name: "Finish follow-up patch" }).click()
|
||||
const group = root.locator('[data-component="collapsed-tool-group"]')
|
||||
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(2)
|
||||
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "a.ts", "c.ts"])
|
||||
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText([
|
||||
"a.ts",
|
||||
"b.ts",
|
||||
"a.ts",
|
||||
"c.ts",
|
||||
"d.ts",
|
||||
])
|
||||
if (separator === "error") await expect(group.locator('[data-kind="tool-error-card"]')).toBeVisible()
|
||||
})
|
||||
}
|
||||
@@ -72,8 +79,14 @@ story("does not retain patch files in the wrong batch when thoughts are shown",
|
||||
await root.getByRole("button", { name: "Finish follow-up patch" }).click()
|
||||
const group = root.locator('[data-component="collapsed-tool-group"]')
|
||||
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(1)
|
||||
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts"])
|
||||
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts", "d.ts"])
|
||||
await root.getByRole("button", { name: "Show thoughts", exact: true }).click()
|
||||
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(2)
|
||||
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "a.ts", "c.ts"])
|
||||
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText([
|
||||
"a.ts",
|
||||
"b.ts",
|
||||
"a.ts",
|
||||
"c.ts",
|
||||
"d.ts",
|
||||
])
|
||||
})
|
||||
|
||||
@@ -29,16 +29,16 @@ for (const open of [true, false]) {
|
||||
await expect(second).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(diff).toBeVisible()
|
||||
const original = await patch.elementHandle()
|
||||
for (const count of [3, 4]) {
|
||||
for (const call of [1, 2]) {
|
||||
await root.getByRole("button", { name: "Append tool call", exact: true }).click()
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
|
||||
).toHaveText("Shell, Patch")
|
||||
await expect(trigger).toHaveAccessibleName(`Used ${count} Shell, Patch`)
|
||||
await expect(trigger).toHaveAccessibleName("Used 2 Shell, Patch")
|
||||
await expect(diff).toBeVisible()
|
||||
await root
|
||||
.locator('[data-component="session-timeline"]')
|
||||
.screenshot({ path: info.outputPath(`append-${count}.png`) })
|
||||
.screenshot({ path: info.outputPath(`append-${call}.png`) })
|
||||
await expect(shell).toHaveAttribute("aria-expanded", String(open))
|
||||
await expect(first).toHaveAttribute("aria-expanded", String(open))
|
||||
await expect(second).toHaveAttribute("aria-expanded", "true")
|
||||
|
||||
@@ -132,7 +132,10 @@ export const PatchFollowUps = {
|
||||
state.phase === "running" ? "running" : "completed",
|
||||
{},
|
||||
{
|
||||
metadata: state.phase === "running" ? {} : { files: [file("src/a.ts", 1, 2), file("src/c.ts", 0, 1)] },
|
||||
metadata:
|
||||
state.phase === "running"
|
||||
? {}
|
||||
: { files: [file("src/a.ts", 1, 2), file("src/c.ts", 0, 1), file("src/d.ts", 0, 1)] },
|
||||
},
|
||||
),
|
||||
]),
|
||||
|
||||
@@ -533,6 +533,15 @@ export function CurrentContextToolGroup(props: {
|
||||
),
|
||||
].join(", "),
|
||||
)
|
||||
const patchedFiles = createMemo(() =>
|
||||
patchFileGroups(
|
||||
tools().flatMap((tool) => {
|
||||
if (tool.name !== "patch" || tool.state.status === "error") return []
|
||||
const files = currentToolMetadata(tool).files
|
||||
return Array.isArray(files) ? files : []
|
||||
}),
|
||||
).length,
|
||||
)
|
||||
const label = createMemo(() => {
|
||||
const thoughts = props.parts.filter((part) => part.type === "reasoning").length
|
||||
if (!names() && !thoughts) {
|
||||
@@ -540,7 +549,8 @@ export function CurrentContextToolGroup(props: {
|
||||
return { text: title, title, before: "", count: "", between: "", after: "" }
|
||||
}
|
||||
const title = names() || i18n.plural("ui.messagePart.context.thought", thoughts)
|
||||
const count = props.parts.filter((part) => part.type === "tool" || part.type === "shell").length || thoughts
|
||||
const count =
|
||||
patchedFiles() || props.parts.filter((part) => part.type === "tool" || part.type === "shell").length || thoughts
|
||||
const text = i18n.plural("ui.messagePart.tools.used", count, { tools: title })
|
||||
const index = text.indexOf(title)
|
||||
const before = text.slice(0, index).trim()
|
||||
|
||||
@@ -169,12 +169,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
: members.some((id) => (data.session.form.list(id)?.length ?? 0) > 0)
|
||||
? ("question" as const)
|
||||
: (false as const),
|
||||
// Parked synthetic context (user shells, plan reminders) stays pending without execution; only work counts as busy.
|
||||
busy: members.some(
|
||||
(id) =>
|
||||
data.session.status(id) === "running" ||
|
||||
data.session.pending.list(id).some((item) => item.type !== "synthetic"),
|
||||
),
|
||||
busy: members.some((id) => data.session.status(id) === "running" || data.session.pending.list(id).length > 0),
|
||||
renaming: data.session.title.pending(session),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4490,34 +4490,6 @@
|
||||
]
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "type",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"agent-switched",
|
||||
"model-switched",
|
||||
"location-switched",
|
||||
"user",
|
||||
"synthetic",
|
||||
"system",
|
||||
"skill",
|
||||
"shell",
|
||||
"assistant",
|
||||
"compaction"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Filter by message type before pagination. When omitted, all message types are returned. Pass the same type when following cursors."
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
@@ -4580,7 +4552,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Retrieve projected messages for a session, optionally filtered by type. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline, passing the same type filter on each page.",
|
||||
"description": "Retrieve projected messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.",
|
||||
"summary": "Get session messages"
|
||||
}
|
||||
},
|
||||
@@ -14271,9 +14243,6 @@
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"websocket": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"modelID": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -14382,9 +14351,6 @@
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"websocket": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"canonical": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -16331,9 +16297,6 @@
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"websocket": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"settings": {
|
||||
"type": "object"
|
||||
},
|
||||
@@ -17322,9 +17285,6 @@
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"websocket": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"settings": {
|
||||
"type": "object"
|
||||
},
|
||||
@@ -18296,12 +18256,6 @@
|
||||
},
|
||||
"providerContext": {
|
||||
"$ref": "#/components/schemas/Session.ProviderContext"
|
||||
},
|
||||
"cost": {
|
||||
"$ref": "#/components/schemas/Money.USD"
|
||||
},
|
||||
"tokens": {
|
||||
"$ref": "#/components/schemas/TokenUsage.Info"
|
||||
}
|
||||
},
|
||||
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
|
||||
@@ -18341,12 +18295,6 @@
|
||||
},
|
||||
"error": {
|
||||
"$ref": "#/components/schemas/Session.StructuredError"
|
||||
},
|
||||
"cost": {
|
||||
"$ref": "#/components/schemas/Money.USD"
|
||||
},
|
||||
"tokens": {
|
||||
"$ref": "#/components/schemas/TokenUsage.Info"
|
||||
}
|
||||
},
|
||||
"required": ["type", "id", "time", "status", "reason", "error"],
|
||||
|
||||
@@ -4490,34 +4490,6 @@
|
||||
]
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "type",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"agent-switched",
|
||||
"model-switched",
|
||||
"location-switched",
|
||||
"user",
|
||||
"synthetic",
|
||||
"system",
|
||||
"skill",
|
||||
"shell",
|
||||
"assistant",
|
||||
"compaction"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Filter by message type before pagination. When omitted, all message types are returned. Pass the same type when following cursors."
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
@@ -4580,7 +4552,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Retrieve projected messages for a session, optionally filtered by type. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline, passing the same type filter on each page.",
|
||||
"description": "Retrieve projected messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.",
|
||||
"summary": "Get session messages"
|
||||
}
|
||||
},
|
||||
@@ -14271,9 +14243,6 @@
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"websocket": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"modelID": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -14382,9 +14351,6 @@
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"websocket": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"canonical": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -16331,9 +16297,6 @@
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"websocket": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"settings": {
|
||||
"type": "object"
|
||||
},
|
||||
@@ -17322,9 +17285,6 @@
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"websocket": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"settings": {
|
||||
"type": "object"
|
||||
},
|
||||
@@ -18296,12 +18256,6 @@
|
||||
},
|
||||
"providerContext": {
|
||||
"$ref": "#/components/schemas/Session.ProviderContext"
|
||||
},
|
||||
"cost": {
|
||||
"$ref": "#/components/schemas/Money.USD"
|
||||
},
|
||||
"tokens": {
|
||||
"$ref": "#/components/schemas/TokenUsage.Info"
|
||||
}
|
||||
},
|
||||
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
|
||||
@@ -18341,12 +18295,6 @@
|
||||
},
|
||||
"error": {
|
||||
"$ref": "#/components/schemas/Session.StructuredError"
|
||||
},
|
||||
"cost": {
|
||||
"$ref": "#/components/schemas/Money.USD"
|
||||
},
|
||||
"tokens": {
|
||||
"$ref": "#/components/schemas/TokenUsage.Info"
|
||||
}
|
||||
},
|
||||
"required": ["type", "id", "time", "status", "reason", "error"],
|
||||
|
||||
@@ -1089,9 +1089,7 @@ effect: (ctx) =>
|
||||
|
||||
### Sessions
|
||||
|
||||
Modify assembled system instructions, messages, or tools immediately before model dispatch. `context` runs for the
|
||||
agent loop; `compaction`, `generate`, and `title` run for those auxiliary requests. `compaction` and `title` accept a
|
||||
`result` that skips the model call.
|
||||
Modify assembled system instructions, messages, or tools immediately before model dispatch.
|
||||
|
||||
```ts
|
||||
effect: (ctx) =>
|
||||
@@ -1103,11 +1101,6 @@ effect: (ctx) =>
|
||||
delete event.tools.write
|
||||
}),
|
||||
)
|
||||
yield* session.hook("compaction", (event) =>
|
||||
Effect.map(summarize(event.messages), (summary) => {
|
||||
event.result = { summary }
|
||||
}),
|
||||
)
|
||||
}),
|
||||
```
|
||||
|
||||
@@ -1151,24 +1144,6 @@ effect: (ctx) =>
|
||||
}),
|
||||
```
|
||||
|
||||
WebSocket providers do not issue one HTTP request per model call, so the HTTP hooks never see that traffic. Three
|
||||
experimental hooks cover it: `experimental.ws.handshake` runs once per model call with the URL and headers the connection
|
||||
needs (changing either reopens the session's socket), `experimental.ws.send` runs on the outbound frame, and
|
||||
`experimental.ws.receive` on every inbound frame. Incremental `send` frames carry only what changed since the provider's
|
||||
last checkpoint; rewriting them changes what the provider sees without changing what OpenCode believes it sent.
|
||||
|
||||
```ts
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.session.hook("experimental.ws.handshake", (event) =>
|
||||
Effect.sync(() => {
|
||||
event.headers.authorization = `Bearer ${token}`
|
||||
}),
|
||||
)
|
||||
yield* ctx.session.hook("experimental.ws.receive", (event) => Effect.log(event.frame))
|
||||
}),
|
||||
```
|
||||
|
||||
Override the retry decision for a provider failure or replace its delay in milliseconds. The hook runs after OpenCode
|
||||
classifies the failure and proposes its policy, but before any retry is scheduled. It does not expose how OpenCode
|
||||
internally performs the next attempt.
|
||||
@@ -1203,15 +1178,9 @@ Context-overflow recovery remains separate because it compacts the conversation
|
||||
```ts
|
||||
interface SessionHooks {
|
||||
readonly context: SessionContext
|
||||
readonly compaction: SessionCompaction
|
||||
readonly generate: SessionGenerate
|
||||
readonly title: SessionTitle
|
||||
readonly "model.request": SessionModelRequest
|
||||
readonly "http.request": SessionHttpRequest
|
||||
readonly "http.response": SessionHttpResponse
|
||||
readonly "experimental.ws.handshake": SessionWebSocketHandshake
|
||||
readonly "experimental.ws.send": SessionWebSocketSend
|
||||
readonly "experimental.ws.receive": SessionWebSocketReceive
|
||||
readonly retry: SessionRetry
|
||||
}
|
||||
|
||||
|
||||
@@ -1197,18 +1197,9 @@ Keep prompt hooks retry-safe. They are not an exactly-once side-effect boundary:
|
||||
- Concurrent submissions can run hooks more than once, but only the first successful admission wins.
|
||||
- Prompt hooks transform input and do not expose a typed rejection API.
|
||||
|
||||
#### Model requests
|
||||
#### Model context
|
||||
|
||||
Modify assembled system instructions, messages, tools, or request options immediately before model dispatch. Each
|
||||
kind of request a session issues has its own hook, so a plugin can treat the agent loop and auxiliary requests
|
||||
differently:
|
||||
|
||||
- `context` runs for the agent loop, including tool-driven continuations.
|
||||
- `compaction` runs for checkpoint summaries. `messages` is the transcript being summarized; OpenCode appends its
|
||||
summary prompt after hooks run. Set `result` to record the compaction yourself and skip the model call; it takes the
|
||||
same fields as a completed compaction message.
|
||||
- `generate` runs for transient `ctx.session.generate` calls.
|
||||
- `title` runs for title generation. It has no `agent` or `tools`. Set `result` to supply the title yourself.
|
||||
Modify assembled system instructions, messages, tools, or request options immediately before model dispatch.
|
||||
|
||||
```ts
|
||||
await ctx.session.hook("context", (event) => {
|
||||
@@ -1217,14 +1208,11 @@ await ctx.session.hook("context", (event) => {
|
||||
event.options.temperature = 0.2
|
||||
event.options.maxTokens = 8_000
|
||||
})
|
||||
|
||||
await ctx.session.hook("compaction", async (event) => {
|
||||
event.result = { summary: await summarize(event.messages) }
|
||||
})
|
||||
```
|
||||
|
||||
Changes affect only the outgoing model call, not persisted history or configuration. A hook that should apply to
|
||||
every request must register for each kind.
|
||||
Context changes affect only the outgoing model call, not persisted history or
|
||||
configuration. The hook runs again for subsequent calls such as tool-driven
|
||||
continuations, transient session generation, and compaction, but not for title requests.
|
||||
|
||||
Request overrides follow these rules:
|
||||
|
||||
@@ -1294,32 +1282,6 @@ await ctx.session.hook("http.response", (event) => {
|
||||
})
|
||||
```
|
||||
|
||||
#### WebSocket (experimental)
|
||||
|
||||
Providers that stream over a WebSocket do not issue one HTTP request per model call, so `http.request` and
|
||||
`http.response` never see that traffic. Three experimental hooks cover it instead. `experimental.ws.handshake` runs
|
||||
once per model call with the URL and headers the connection needs; changing either reopens the session's socket.
|
||||
`experimental.ws.send` runs on the outbound frame, and `experimental.ws.receive` on every inbound frame. All three carry
|
||||
the same `sessionID`, `agent`, `model`, and `kind` as the HTTP hooks.
|
||||
|
||||
```ts
|
||||
await ctx.session.hook("experimental.ws.handshake", (event) => {
|
||||
event.headers.authorization = `Bearer ${token}`
|
||||
})
|
||||
|
||||
await ctx.session.hook("experimental.ws.send", (event) => {
|
||||
if (event.mode === "full") event.frame = redact(event.frame)
|
||||
})
|
||||
|
||||
await ctx.session.hook("experimental.ws.receive", (event) => {
|
||||
log(event.frame)
|
||||
})
|
||||
```
|
||||
|
||||
`send` frames in `"incremental"` mode carry only what changed since the provider's last checkpoint. Rewriting them
|
||||
changes what the provider sees without changing what OpenCode believes it sent, so treat them as read-only unless you
|
||||
also handle the resulting drift.
|
||||
|
||||
#### Retry policy
|
||||
|
||||
Override the retry decision for a provider failure or replace its delay in milliseconds. The hook runs after OpenCode
|
||||
@@ -1359,15 +1321,9 @@ import type { SessionPrompt } from "@opencode/plugin/promise/session"
|
||||
interface SessionHooks {
|
||||
prompt: SessionPrompt
|
||||
context: SessionContextHook
|
||||
compaction: SessionContextHook & { result?: SessionCompactionResult }
|
||||
generate: SessionContextHook
|
||||
title: SessionRequestHook & { result?: string }
|
||||
"model.request": SessionModelRequestHook
|
||||
"http.request": SessionHttpRequestHook
|
||||
"http.response": SessionHttpResponseHook
|
||||
"experimental.ws.handshake": SessionWebSocketHandshakeHook
|
||||
"experimental.ws.send": SessionWebSocketSendHook
|
||||
"experimental.ws.receive": SessionWebSocketReceiveHook
|
||||
retry: SessionRetryHook
|
||||
}
|
||||
|
||||
@@ -1382,11 +1338,13 @@ interface SessionRetryHook {
|
||||
decision: RetryDecision
|
||||
}
|
||||
|
||||
interface SessionRequestHook {
|
||||
interface SessionContextHook {
|
||||
readonly sessionID: string
|
||||
readonly agent: string
|
||||
readonly model: { providerID: string; id: string; variant?: string }
|
||||
system: SystemPart[]
|
||||
messages: Message[]
|
||||
tools: Record<string, { description: string; input: JsonSchema }>
|
||||
options: {
|
||||
maxTokens?: number
|
||||
temperature?: number
|
||||
@@ -1399,18 +1357,6 @@ interface SessionRequestHook {
|
||||
} & Record<string, unknown>
|
||||
}
|
||||
|
||||
interface SessionContextHook extends SessionRequestHook {
|
||||
readonly agent: string
|
||||
tools: Record<string, { description: string; input: JsonSchema }>
|
||||
}
|
||||
|
||||
interface SessionCompactionResult {
|
||||
summary: string
|
||||
providerState?: Record<string, unknown>
|
||||
metadata?: Record<string, unknown>
|
||||
tokens?: TokenUsage
|
||||
}
|
||||
|
||||
interface SessionHookContext {
|
||||
hook<Name extends keyof SessionHooks>(
|
||||
name: Name,
|
||||
|
||||
Reference in New Issue
Block a user