mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-12 11:56:23 +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,74 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { sessionID, setupTimeline, userMessage } from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("keeps a submitted prompt in place while its optimistic rows are measured", async ({ page }) => {
|
||||
await setupTimeline(page, { messages: [userMessage()], seedHistory: true })
|
||||
const release = Promise.withResolvers<void>()
|
||||
await page.route(`**/api/session/${sessionID}/prompt`, async (route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
await release.promise
|
||||
return route.fallback()
|
||||
})
|
||||
|
||||
const editor = page.locator('[data-component="composer"]').getByRole("textbox")
|
||||
await expect(editor).toBeEditable()
|
||||
await editor.fill("Observe optimistic prompt spacing.")
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.locator("[data-timeline-virtual-content]").evaluate((element) => {
|
||||
const root = element.parentElement!
|
||||
return root.scrollHeight - root.clientHeight - root.scrollTop
|
||||
}),
|
||||
)
|
||||
.toBe(0)
|
||||
|
||||
const observation = await page.evaluateHandle(() => {
|
||||
const frames: { prompt?: number; working: boolean }[] = []
|
||||
let frame = 0
|
||||
const sample = () => {
|
||||
const prompt = [...document.querySelectorAll<HTMLElement>('[data-timeline-row="UserMessage"]')].find((row) =>
|
||||
row.textContent?.includes("Observe optimistic prompt spacing."),
|
||||
)
|
||||
frames.push({
|
||||
...(prompt ? { prompt: prompt.getBoundingClientRect().y } : {}),
|
||||
working: !!document.querySelector('[data-component="session-working"]'),
|
||||
})
|
||||
frame = requestAnimationFrame(sample)
|
||||
}
|
||||
frame = requestAnimationFrame(sample)
|
||||
return {
|
||||
stop: () => {
|
||||
cancelAnimationFrame(frame)
|
||||
return frames
|
||||
},
|
||||
}
|
||||
})
|
||||
const requested = page.waitForRequest(
|
||||
(request) => request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/prompt`,
|
||||
)
|
||||
try {
|
||||
await editor.press("Enter")
|
||||
await requested
|
||||
const prompt = page
|
||||
.locator('[data-timeline-row="UserMessage"]')
|
||||
.filter({ hasText: "Observe optimistic prompt spacing." })
|
||||
await expect(prompt).toBeInViewport()
|
||||
await expect(page.locator('[data-component="session-working"]')).toBeVisible()
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.locator("[data-timeline-virtual-content]").evaluate((element) => {
|
||||
const root = element.parentElement!
|
||||
return root.scrollHeight - root.clientHeight - root.scrollTop
|
||||
}),
|
||||
)
|
||||
.toBe(0)
|
||||
const frames = await observation.evaluate((value) => value.stop())
|
||||
expect(frames.some((frame) => frame.working && frame.prompt === undefined)).toBe(false)
|
||||
const positions = frames.flatMap((frame) => (frame.prompt === undefined ? [] : [frame.prompt]))
|
||||
expect(positions.length).toBeGreaterThan(0)
|
||||
expect(new Set(positions).size).toBe(1)
|
||||
} finally {
|
||||
release.resolve()
|
||||
await observation.dispose()
|
||||
}
|
||||
})
|
||||
@@ -122,15 +122,7 @@ test("renders compaction progress, summary, and outcome in order", async ({ page
|
||||
)
|
||||
await expect(compaction.getByRole("heading", { name: "Checkpoint" })).toBeVisible()
|
||||
await expect(compaction).toContainText("Streamed implementation details.")
|
||||
const running = compaction.getByRole("status").getByLabel("Compacting", { exact: true })
|
||||
await expect(running).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const summary = await compaction.locator('[data-component="text-part"]').boundingBox()
|
||||
const status = await running.boundingBox()
|
||||
return !!summary && !!status && status.y >= summary.y + summary.height
|
||||
})
|
||||
.toBe(true)
|
||||
await expect(compaction.getByRole("status").getByLabel("Compacting", { exact: true })).toBeVisible()
|
||||
await expect(compaction.getByText("Session compacted", { exact: true })).toHaveCount(0)
|
||||
|
||||
await timeline.send(
|
||||
|
||||
@@ -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") })
|
||||
})
|
||||
@@ -88,12 +88,8 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
if (value.mode === "normal" && !command) {
|
||||
session.handoff?.set(handoffMessage(value))
|
||||
const optimisticBusy = !input.adapter.working()
|
||||
if (optimisticBusy && input.adapter.kind === "new-session")
|
||||
session.data.session.setStatus(session.id, "running")
|
||||
const sending = sendPrompt(session, value, input.adapter.controls().model.selection.trackSessionCommit, () => {
|
||||
if (optimisticBusy && input.adapter.kind === "active-session")
|
||||
session.data.session.setStatus(session.id, "running")
|
||||
}).then(
|
||||
if (optimisticBusy) session.data.session.setStatus(session.id, "running")
|
||||
const sending = sendPrompt(session, value, input.adapter.controls().model.selection.trackSessionCommit).then(
|
||||
() => ({ ok: true as const }),
|
||||
(error) => ({ ok: false as const, error }),
|
||||
)
|
||||
@@ -363,8 +359,7 @@ async function applySelection(
|
||||
async function sendPrompt(
|
||||
session: ComposerSession,
|
||||
value: ComposerSubmission,
|
||||
track: ModelSelection["trackSessionCommit"] | undefined,
|
||||
onAdmit: () => void,
|
||||
track?: ModelSelection["trackSessionCommit"],
|
||||
) {
|
||||
const request = await buildSubmissionRequest(session, value)
|
||||
// Switching agent or model reconfigures the session immediately, and with it
|
||||
@@ -394,9 +389,7 @@ async function sendPrompt(
|
||||
},
|
||||
},
|
||||
}
|
||||
const sending = session.data.session.prompt(admission).catch(() => session.data.session.prompt(admission))
|
||||
onAdmit()
|
||||
await sending
|
||||
await session.data.session.prompt(admission).catch(() => session.data.session.prompt(admission))
|
||||
}
|
||||
|
||||
async function buildSubmissionRequest(session: ComposerSession, value: ComposerSubmission) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -515,19 +515,6 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
<div
|
||||
ref={(value) => {
|
||||
element = value
|
||||
if (row()._tag !== "UserMessage" || !addedKeys.has(rowProps.rowKey) || !input.pinned() || coldPending)
|
||||
return
|
||||
// The optimistic row can paint before ResizeObserver corrects the tail estimates.
|
||||
// Measure the mounted tail and pin it in this render's microtask instead.
|
||||
queueMicrotask(() => {
|
||||
if (!input.pinned() || !virtualContent?.isConnected) return
|
||||
virtualizer.elementsCache.forEach((item) => {
|
||||
if (item.isConnected) virtualizer.resizeItem(virtualizer.indexFromElement(item), item.offsetHeight)
|
||||
})
|
||||
virtualizer.resizeItem(item().index, element.offsetHeight)
|
||||
virtualContent.style.height = `${virtualizer.getTotalSize()}px`
|
||||
virtualizer.scrollToEnd()
|
||||
})
|
||||
}}
|
||||
data-index={item().index}
|
||||
style={{ "min-height": ready() ? undefined : `${initialItem.size}px` }}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -17,7 +17,6 @@ import type { PromptInput } from "@opencode/schema/prompt-input"
|
||||
import type { AgentAttachment } from "@opencode/schema/prompt"
|
||||
import type { Skill } from "@opencode/schema/skill"
|
||||
import type { Event } from "@opencode/schema/event"
|
||||
import type { FileDiff } from "@opencode/schema/file-diff"
|
||||
import type { InstructionEntry } from "@opencode/schema/instruction-entry"
|
||||
import type { Schema } from "effect"
|
||||
import type { EventLog } from "@opencode/schema/event-log"
|
||||
@@ -37,6 +36,7 @@ import type { PtyTicket } from "@opencode/schema/pty-ticket"
|
||||
import type { Reference } from "@opencode/schema/reference"
|
||||
import type { Worktree } from "@opencode/schema/worktree"
|
||||
import type { Vcs } from "@opencode/schema/vcs"
|
||||
import type { FileDiff } from "@opencode/schema/file-diff"
|
||||
import type { WebSearch } from "@opencode/schema/websearch"
|
||||
import type { Config } from "@opencode/schema/config"
|
||||
|
||||
@@ -360,15 +360,6 @@ export type SessionContextInput = { readonly sessionID: Session.ID }
|
||||
export type SessionContextOutput = ReadonlyArray<SessionMessage.Info>
|
||||
export type SessionContextOperation<E = never> = (input: SessionContextInput) => Effect.Effect<SessionContextOutput, E>
|
||||
|
||||
export type SessionDiffInput = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly messageID?: SessionMessage.ID | undefined
|
||||
readonly to?: SessionMessage.ID | undefined
|
||||
readonly context?: number | undefined
|
||||
}
|
||||
export type SessionDiffOutput = ReadonlyArray<FileDiff.Info>
|
||||
export type SessionDiffOperation<E = never> = (input: SessionDiffInput) => Effect.Effect<SessionDiffOutput, E>
|
||||
|
||||
export type SessionInboxListInput = { readonly sessionID: Session.ID }
|
||||
export type SessionInboxListOutput = ReadonlyArray<SessionInbox.Info>
|
||||
export type SessionInboxListOperation<E = never> = (
|
||||
@@ -1148,7 +1139,6 @@ export interface SessionApi<E = never> {
|
||||
readonly commit: SessionRevertCommitOperation<E>
|
||||
}
|
||||
readonly context: SessionContextOperation<E>
|
||||
readonly diff: SessionDiffOperation<E>
|
||||
readonly inbox: {
|
||||
readonly list: SessionInboxListOperation<E>
|
||||
readonly cancel: SessionInboxCancelOperation<E>
|
||||
@@ -1176,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>
|
||||
|
||||
@@ -68,8 +68,6 @@ import type {
|
||||
SessionRevertCommitOutput,
|
||||
SessionContextInput,
|
||||
SessionContextOutput,
|
||||
SessionDiffInput,
|
||||
SessionDiffOutput,
|
||||
SessionInboxListInput,
|
||||
SessionInboxListOutput,
|
||||
SessionInboxCancelInput,
|
||||
@@ -594,17 +592,6 @@ const EndpointSessionContext = (raw: RawClient["server.session"]) => (input: Ses
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointSessionDiff = (raw: RawClient["server.session"]) => (input: SessionDiffInput) =>
|
||||
preserveEffect<SessionDiffOutput>()(
|
||||
raw["session.diff"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
query: { messageID: input["messageID"], to: input["to"], context: input["context"] },
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointSessionInboxList = (raw: RawClient["server.session"]) => (input: SessionInboxListInput) =>
|
||||
preserveEffect<SessionInboxListOutput>()(
|
||||
raw["session.inbox.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
@@ -744,7 +731,6 @@ const adaptGroupSession = (raw: RawClient["server.session"]) => ({
|
||||
commit: EndpointSessionRevertCommit(raw),
|
||||
},
|
||||
context: EndpointSessionContext(raw),
|
||||
diff: EndpointSessionDiff(raw),
|
||||
inbox: {
|
||||
list: EndpointSessionInboxList(raw),
|
||||
cancel: EndpointSessionInboxCancel(raw),
|
||||
@@ -771,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)),
|
||||
)
|
||||
|
||||
|
||||
@@ -62,8 +62,6 @@ import type {
|
||||
SessionRevertCommitOutput,
|
||||
SessionContextInput,
|
||||
SessionContextOutput,
|
||||
SessionDiffInput,
|
||||
SessionDiffOutput,
|
||||
SessionInboxListInput,
|
||||
SessionInboxListOutput,
|
||||
SessionInboxCancelInput,
|
||||
@@ -844,18 +842,6 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
diff: (input: SessionDiffInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionDiffOutput }>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/diff`,
|
||||
query: { messageID: input["messageID"], to: input["to"], context: input["context"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401, 404, 500],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
inbox: {
|
||||
list: (input: SessionInboxListInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionInboxListOutput }>(
|
||||
@@ -1029,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,
|
||||
|
||||
@@ -147,14 +147,6 @@ export type SessionProviderContextProvenance = {
|
||||
endpoint: string
|
||||
}
|
||||
|
||||
export type SessionMessageIdle = {
|
||||
id: string
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
time: { created: number }
|
||||
type: "idle"
|
||||
outcome: "succeeded" | "failed" | "interrupted"
|
||||
}
|
||||
|
||||
export type SessionActive = { type: "running" }
|
||||
|
||||
export type SessionInboxDelivery = "steer" | "queue"
|
||||
@@ -2202,7 +2194,6 @@ export type SessionMessageInfo =
|
||||
| SessionMessageShell
|
||||
| SessionMessageAssistant
|
||||
| SessionMessageCompaction
|
||||
| SessionMessageIdle
|
||||
|
||||
export type SessionMessageContentUpdated = {
|
||||
id: string
|
||||
@@ -3161,13 +3152,6 @@ export type SessionImportInput = {
|
||||
}
|
||||
}
|
||||
)
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "idle"
|
||||
readonly outcome: "succeeded" | "failed" | "interrupted"
|
||||
}
|
||||
>
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
}["info"]
|
||||
@@ -3473,13 +3457,6 @@ export type SessionImportInput = {
|
||||
}
|
||||
}
|
||||
)
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "idle"
|
||||
readonly outcome: "succeeded" | "failed" | "interrupted"
|
||||
}
|
||||
>
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
}["messages"]
|
||||
@@ -3785,13 +3762,6 @@ export type SessionImportInput = {
|
||||
}
|
||||
}
|
||||
)
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "idle"
|
||||
readonly outcome: "succeeded" | "failed" | "interrupted"
|
||||
}
|
||||
>
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
}["location"]
|
||||
@@ -4281,27 +4251,6 @@ export type SessionContextInput = { readonly sessionID: { readonly sessionID: st
|
||||
|
||||
export type SessionContextOutput = { data: Array<SessionMessageInfo> }["data"]
|
||||
|
||||
export type SessionDiffInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly messageID?: {
|
||||
readonly messageID?: string | undefined
|
||||
readonly to?: string | undefined
|
||||
readonly context?: number | undefined
|
||||
}["messageID"]
|
||||
readonly to?: {
|
||||
readonly messageID?: string | undefined
|
||||
readonly to?: string | undefined
|
||||
readonly context?: number | undefined
|
||||
}["to"]
|
||||
readonly context?: {
|
||||
readonly messageID?: string | undefined
|
||||
readonly to?: string | undefined
|
||||
readonly context?: number | undefined
|
||||
}["context"]
|
||||
}
|
||||
|
||||
export type SessionDiffOutput = { data: Array<FileDiffInfo> }["data"]
|
||||
|
||||
export type SessionInboxListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type SessionInboxListOutput = { data: Array<SessionInboxInfo> }["data"]
|
||||
@@ -4399,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
|
||||
|
||||
@@ -1024,18 +1024,6 @@ export function createData(config: CreateDataInput) {
|
||||
if (currentAssistant) currentAssistant.retry = undefined
|
||||
})
|
||||
if (event.type === "session.execution.interrupted" && event.data.reason === "shutdown") return
|
||||
// Mirror the projected idle marker so turn boundaries match before the next message read.
|
||||
message.insert(event.data.sessionID, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "idle",
|
||||
outcome:
|
||||
event.type === "session.execution.succeeded"
|
||||
? "succeeded"
|
||||
: event.type === "session.execution.failed"
|
||||
? "failed"
|
||||
: "interrupted",
|
||||
time: { created: event.created },
|
||||
})
|
||||
// An event can overtake the first read; queue a revalidation when that read is still active.
|
||||
if (!store.session.info[event.data.sessionID] && !sync.has(`session:${event.data.sessionID}`)) return
|
||||
result.session.invalidate(event.data.sessionID)
|
||||
|
||||
@@ -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,21 +44,18 @@ ultimate source of truth.
|
||||
|
||||
## Bindings and destructuring
|
||||
|
||||
- [x] `const`, `let`, and `var` declarations.
|
||||
- [x] `const`, `let`, and accepted `var` declarations.
|
||||
- [x] Object and array destructuring in declarations, parameters, assignment expressions, and `for...of` bindings.
|
||||
- [x] Nested patterns, defaults, elisions, and rest elements.
|
||||
- [x] Assignment to identifiers, plain-object fields, non-negative integer array indexes, and writable URL
|
||||
fields.
|
||||
- [x] Direct function declarations are hoisted in program and block statement lists.
|
||||
- [x] Parameter defaults observe a temporal dead zone for later parameters.
|
||||
- [x] `var` is function-scoped and hoisted: names declared anywhere in a function or program body, including loop
|
||||
heads, blocks, `switch` cases, and `try`/`catch`, read as `undefined` before their statement runs; redeclaration
|
||||
assigns the one binding; a same-named parameter keeps its argument; closures in parameter defaults see outer
|
||||
names rather than body `var`s.
|
||||
- [ ] JavaScript-correct function scoping, hoisting, and redeclaration for accepted `var` declarations.
|
||||
- [x] Predeclare `let` and `const` bindings in every lexical scope, including program/block bodies, switch bodies, and
|
||||
loop headers, so reads before initialization and self- or cross-referential initializers observe the JavaScript
|
||||
temporal dead zone.
|
||||
- [x] Function declarations are hoisted across all cases of a `switch`, like any other statement list.
|
||||
- [ ] Hoist function declarations accepted directly in switch cases.
|
||||
- [x] Computed object destructuring keys such as `const { [field]: value } = record`.
|
||||
- [x] Object destructuring from arrays, such as `const { length } = values`.
|
||||
- [x] Array binding and assignment destructuring from strings, Maps, Sets, URLSearchParams, custom synchronous
|
||||
@@ -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.
|
||||
@@ -364,14 +356,6 @@ ultimate source of truth.
|
||||
`entries`, `toString`, and `size`.
|
||||
- [x] URL values serialize to their href; URLSearchParams serialize to `{}`.
|
||||
|
||||
## Web platform helpers
|
||||
|
||||
- [x] `atob` and `btoa` with forgiving-base64 decoding and WebIDL string conversion; invalid input throws an Error
|
||||
named `InvalidCharacterError`, since there is no `DOMException`.
|
||||
- [x] `crypto.randomUUID()`.
|
||||
- [ ] `crypto.getRandomValues` and `crypto.subtle`, `TextEncoder`/`TextDecoder`, and `Blob`: these need a binary
|
||||
value type, which the JSON-like data model does not have yet.
|
||||
|
||||
## Errors and diagnostics
|
||||
|
||||
- [x] `Error`, `TypeError`, `RangeError`, `SyntaxError`, `ReferenceError`, `EvalError`, and `URIError`, callable with
|
||||
@@ -388,6 +372,6 @@ ultimate source of truth.
|
||||
shift them. The diagnostic names the rejected node type and attaches a short orientation to the supported
|
||||
subset; this matrix is the full reference.
|
||||
- [x] Model-visible host failure messages and underlying causes, including output-validation errors.
|
||||
- [x] Caught errors do not distinguish user throws, interpreter failures, and tool failures; a program sees one
|
||||
Error-shaped value with `name` and `message` in `catch`, rejection handlers, and `Promise.allSettled` reasons.
|
||||
This is deliberate: the program should handle a failure the same way regardless of where it originated.
|
||||
- [ ] Distinguish user-thrown failures from interpreter defects and explicit tool refusals from internal tool
|
||||
failures; preserve those categories in caught errors, promise rejection handlers, and `Promise.allSettled`
|
||||
reasons.
|
||||
|
||||
@@ -105,7 +105,7 @@ export type Result = typeof Result.Type
|
||||
|
||||
/** Reusable confined runtime over explicit tools. */
|
||||
export type Runtime<R = never> = {
|
||||
readonly catalog: ReadonlyArray<ToolDescription>
|
||||
readonly catalog: () => ReadonlyArray<ToolDescription>
|
||||
readonly execute: (code: string) => Effect.Effect<Result, never, R>
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ export const make = <const Provided extends Record<string, unknown> = {}>(
|
||||
const prepared = ToolRuntime.prepare((options.tools ?? {}) as Tools<Services<Provided>>)
|
||||
const limits = resolveExecutionLimits(options.limits)
|
||||
return {
|
||||
catalog: prepared.catalog,
|
||||
catalog: () => prepared.catalog,
|
||||
execute: (code) => executeProgram(code, prepared, limits, options),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import { regexpGlobal } from "../stdlib/regexp.js"
|
||||
import { stringGlobal } from "../stdlib/string.js"
|
||||
import { uriGlobal, urlGlobal, urlSearchParamsGlobal } from "../stdlib/url.js"
|
||||
import { coercion, errorConstructors } from "../stdlib/value.js"
|
||||
import { atobGlobal, btoaGlobal, cryptoGlobal } from "../stdlib/web.js"
|
||||
import { ToolReference } from "../tool-runtime.js"
|
||||
import { errorGlobal } from "./errors.js"
|
||||
import { HostFunction } from "./host.js"
|
||||
@@ -72,8 +71,5 @@ export const globals = <R>(host: Host<R>): ReadonlyArray<readonly [string, unkno
|
||||
["encodeURIComponent", uriGlobal("encodeURIComponent")],
|
||||
["decodeURI", uriGlobal("decodeURI")],
|
||||
["decodeURIComponent", uriGlobal("decodeURIComponent")],
|
||||
["atob", atobGlobal],
|
||||
["btoa", btoaGlobal],
|
||||
["crypto", cryptoGlobal],
|
||||
...[...errorConstructors].map((name) => [name, errorGlobal(name, host.runner)] as const),
|
||||
]
|
||||
|
||||
@@ -118,11 +118,9 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
|
||||
result = value.trim()
|
||||
break
|
||||
case "trimStart":
|
||||
case "trimLeft":
|
||||
result = value.trimStart()
|
||||
break
|
||||
case "trimEnd":
|
||||
case "trimRight":
|
||||
result = value.trimEnd()
|
||||
break
|
||||
// Locale/options are deliberately unsupported; comparison uses the host default locale.
|
||||
@@ -243,15 +241,6 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
|
||||
case "substring":
|
||||
result = value.substring(optNum(0) ?? 0, optNum(1))
|
||||
break
|
||||
case "substr":
|
||||
result = value.substr(optNum(0) ?? 0, optNum(1))
|
||||
break
|
||||
case "isWellFormed":
|
||||
result = value.isWellFormed()
|
||||
break
|
||||
case "toWellFormed":
|
||||
result = value.toWellFormed()
|
||||
break
|
||||
case "charCodeAt":
|
||||
result = value.charCodeAt(optNum(0) ?? 0)
|
||||
break
|
||||
|
||||
@@ -32,17 +32,6 @@ export class PromiseRuntime<R> {
|
||||
|
||||
constructor(private readonly scope: Scope.Scope) {}
|
||||
|
||||
// Resolution bodies need the promise's own identity to reject `resolve(promise)` self-resolution.
|
||||
createWithSelf(
|
||||
body: (self: { promise?: Values.Promise }) => Effect.Effect<unknown, unknown, R>,
|
||||
): Effect.Effect<Values.Promise, never, R> {
|
||||
const self: { promise?: Values.Promise } = {}
|
||||
return Effect.map(this.create(body(self)), (promise) => {
|
||||
self.promise = promise
|
||||
return promise
|
||||
})
|
||||
}
|
||||
|
||||
create(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<Values.Promise, never, R> {
|
||||
return Effect.suspend(() => {
|
||||
// Allocate before forking so reruns get distinct IDs and diagnostics retain creation order.
|
||||
@@ -137,7 +126,11 @@ export const resolvePromise = <R>(
|
||||
node: AstNode,
|
||||
): Effect.Effect<Values.Promise, never, R> => {
|
||||
if (value instanceof Values.Promise) return Effect.succeed(value)
|
||||
return promises.createWithSelf((self) => resolvePromiseValue(runner, value, node, self))
|
||||
const box: { promise?: Values.Promise } = {}
|
||||
return Effect.map(promises.create(resolvePromiseValue(runner, value, node, box)), (promise) => {
|
||||
box.promise = promise
|
||||
return promise
|
||||
})
|
||||
}
|
||||
|
||||
const promiseStatics = ["all", "allSettled", "race", "any", "resolve", "reject"] as const
|
||||
@@ -261,9 +254,11 @@ const constructPromise = <R>(
|
||||
}
|
||||
return Effect.gen(function* () {
|
||||
const deferred = Deferred.makeUnsafe<unknown, unknown>()
|
||||
const promise = yield* promises.createWithSelf((self) =>
|
||||
Effect.flatMap(Deferred.await(deferred), (value) => resolvePromiseValue(runner, value, node, self)),
|
||||
const box: { promise?: Values.Promise } = {}
|
||||
const promise = yield* promises.create(
|
||||
Effect.flatMap(Deferred.await(deferred), (value) => resolvePromiseValue(runner, value, node, box)),
|
||||
)
|
||||
box.promise = promise
|
||||
const resolve = capability("resolve", (value) => Deferred.doneUnsafe(deferred, Exit.succeed(value)))
|
||||
const reject = capability("reject", (value) => Deferred.doneUnsafe(deferred, Exit.fail(new ProgramThrow(value))))
|
||||
const executed = yield* Effect.exit(runner.invokeFunction(executor, [resolve, reject]))
|
||||
@@ -315,16 +310,19 @@ const chainReaction = <R>(
|
||||
method: string,
|
||||
node: AstNode,
|
||||
): Effect.Effect<Values.Promise, never, R> => {
|
||||
return promises.createWithSelf((self) =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* reactionExit(promises, source)
|
||||
const handler = Exit.isSuccess(exit) ? onFulfilled : onRejected
|
||||
if (handler === undefined) return yield* exit
|
||||
const input = Exit.isSuccess(exit) ? exit.value : caughtErrorValue(Cause.squash(exit.cause))
|
||||
const result = yield* applyCollectionCallback(runner, handler, method, node)([input])
|
||||
return yield* resolvePromiseValue(runner, result, node, self)
|
||||
}),
|
||||
)
|
||||
const box: { promise?: Values.Promise } = {}
|
||||
const body = Effect.gen(function* () {
|
||||
const exit = yield* reactionExit(promises, source)
|
||||
const handler = Exit.isSuccess(exit) ? onFulfilled : onRejected
|
||||
if (handler === undefined) return yield* exit
|
||||
const input = Exit.isSuccess(exit) ? exit.value : caughtErrorValue(Cause.squash(exit.cause))
|
||||
const result = yield* applyCollectionCallback(runner, handler, method, node)([input])
|
||||
return yield* resolvePromiseValue(runner, result, node, box)
|
||||
})
|
||||
return Effect.map(promises.create(body), (derived) => {
|
||||
box.promise = derived
|
||||
return derived
|
||||
})
|
||||
}
|
||||
|
||||
const chainFinally = <R>(
|
||||
|
||||
@@ -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,51 +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.
|
||||
// Memoized per body: a function's var names never change, and hoisting runs on every call.
|
||||
const varNames = new WeakMap<ReadonlyArray<Statement | ModuleDeclaration>, ReadonlyArray<string>>()
|
||||
const collectVarNames = (
|
||||
node: Statement | ModuleDeclaration | null | undefined,
|
||||
out: Array<string> = [],
|
||||
): Array<string> => {
|
||||
if (!node) return out
|
||||
switch (node.type) {
|
||||
case "VariableDeclaration":
|
||||
if (node.kind === "var") for (const declaration of node.declarations) collectPatternNames(declaration.id, out)
|
||||
break
|
||||
case "BlockStatement":
|
||||
for (const statement of node.body) collectVarNames(statement, out)
|
||||
break
|
||||
case "IfStatement":
|
||||
collectVarNames(node.consequent, out)
|
||||
collectVarNames(node.alternate, out)
|
||||
break
|
||||
case "ForStatement":
|
||||
if (node.init?.type === "VariableDeclaration") collectVarNames(node.init, out)
|
||||
collectVarNames(node.body, out)
|
||||
break
|
||||
case "ForInStatement":
|
||||
case "ForOfStatement":
|
||||
if (node.left.type === "VariableDeclaration") collectVarNames(node.left, out)
|
||||
collectVarNames(node.body, out)
|
||||
break
|
||||
case "WhileStatement":
|
||||
case "DoWhileStatement":
|
||||
case "LabeledStatement":
|
||||
collectVarNames(node.body, out)
|
||||
break
|
||||
case "SwitchStatement":
|
||||
for (const item of node.cases) for (const statement of item.consequent) collectVarNames(statement, out)
|
||||
break
|
||||
case "TryStatement":
|
||||
collectVarNames(node.block, out)
|
||||
collectVarNames(node.handler?.body, out)
|
||||
collectVarNames(node.finalizer, out)
|
||||
break
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const loopDeclaration = (left: VariableDeclaration | Pattern, statement: "for...of" | "for...in") => {
|
||||
if (left.type !== "VariableDeclaration") return undefined
|
||||
const declaration = left.declarations.length === 1 ? left.declarations[0] : undefined
|
||||
@@ -275,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(
|
||||
@@ -295,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> {
|
||||
@@ -321,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") {
|
||||
@@ -445,20 +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 names =
|
||||
varNames.get(statements) ??
|
||||
statements.reduce<Array<string>>((out, statement) => collectVarNames(statement, out), [])
|
||||
varNames.set(statements, names)
|
||||
const scope = this.scopes.current()
|
||||
for (const name of names) {
|
||||
if (scope.has(name)) continue
|
||||
scope.set(name, { mutable: true, value: parameters?.get(name)?.value, initialized: true })
|
||||
}
|
||||
}
|
||||
|
||||
private predeclareLexical(statements: ReadonlyArray<Statement | ModuleDeclaration>): void {
|
||||
for (const statement of statements) {
|
||||
if (statement.type !== "VariableDeclaration") continue
|
||||
@@ -496,9 +409,7 @@ class Frame<R> {
|
||||
self.scopes.push()
|
||||
return yield* Effect.gen(function* () {
|
||||
const cases = node.cases
|
||||
const statements = cases.flatMap((branch) => branch.consequent)
|
||||
self.predeclareLexical(statements)
|
||||
self.hoistFunctions(statements)
|
||||
self.predeclareLexical(cases.flatMap((branch) => branch.consequent))
|
||||
let defaultIndex: number | undefined
|
||||
let selected: number | undefined
|
||||
for (const [index, branch] of cases.entries()) {
|
||||
@@ -540,8 +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
|
||||
@@ -555,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
|
||||
@@ -602,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
|
||||
@@ -651,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)
|
||||
}
|
||||
@@ -664,7 +613,7 @@ class Frame<R> {
|
||||
}).pipe(
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
if (declared?.lexical) self.scopes.pop()
|
||||
if (declared) self.scopes.pop()
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -683,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(
|
||||
@@ -869,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(
|
||||
@@ -889,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)
|
||||
@@ -898,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)
|
||||
}
|
||||
@@ -911,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
|
||||
@@ -1018,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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1645,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
|
||||
}
|
||||
@@ -1655,8 +1630,16 @@ class Frame<R> {
|
||||
})
|
||||
if (fn.generator) return Effect.succeed(this.createGenerator(invocation, run, fn.async))
|
||||
if (!fn.async) return run
|
||||
return this.runtime.promises.createWithSelf((self) =>
|
||||
Effect.flatMap(run, (value) => resolvePromiseValue(invocation.runtime.runner, value, fn.body, self)),
|
||||
// The initial yield assigns the promise before the body can self-resolve.
|
||||
const box: { promise?: Values.Promise } = {}
|
||||
return Effect.map(
|
||||
this.createPromise(
|
||||
Effect.flatMap(run, (value) => resolvePromiseValue(invocation.runtime.runner, value, fn.body, box)),
|
||||
),
|
||||
(promise) => {
|
||||
box.promise = promise
|
||||
return promise
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1911,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
|
||||
}
|
||||
|
||||
@@ -2013,7 +2002,7 @@ class Frame<R> {
|
||||
|
||||
private getMemberReference(
|
||||
node: MemberExpression,
|
||||
operation: "read" | "write" | "delete" = "read",
|
||||
operation: "read" | "delete" = "read",
|
||||
): Effect.Effect<
|
||||
| MemberReference
|
||||
| ToolReference
|
||||
@@ -2054,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)
|
||||
@@ -2215,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) =>
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { HostNamespace, sync } from "../interpreter/host.js"
|
||||
import { InterpreterRuntimeError } from "../interpreter/model.js"
|
||||
import { coerceToString } from "./value.js"
|
||||
|
||||
// WebIDL DOMString conversion: a missing argument is a TypeError, anything else stringifies.
|
||||
const base64 = (name: "atob" | "btoa") =>
|
||||
sync(name, (args, node) => {
|
||||
if (args.length === 0) {
|
||||
throw new InterpreterRuntimeError(`${name} requires 1 argument, but only 0 were provided.`, node).as("TypeError")
|
||||
}
|
||||
const input = coerceToString(args[0])
|
||||
try {
|
||||
return name === "atob" ? atob(input) : btoa(input)
|
||||
} catch {
|
||||
throw new InterpreterRuntimeError("The string contains invalid characters.", node).as("InvalidCharacterError")
|
||||
}
|
||||
})
|
||||
|
||||
export const atobGlobal = base64("atob")
|
||||
export const btoaGlobal = base64("btoa")
|
||||
|
||||
export const cryptoGlobal = new HostNamespace("crypto", {
|
||||
randomUUID: sync("crypto.randomUUID", () => crypto.randomUUID()),
|
||||
})
|
||||
@@ -1,11 +0,0 @@
|
||||
# The 3-Clause BSD License
|
||||
|
||||
Copyright © web-platform-tests contributors
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
||||
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
||||
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -528,7 +528,7 @@ describe("CodeMode schema flexibility", () => {
|
||||
})
|
||||
const runtime = CodeMode.make({ tools: { adapter: { call } } })
|
||||
|
||||
expect(runtime.catalog).toStrictEqual([
|
||||
expect(runtime.catalog()).toStrictEqual([
|
||||
{
|
||||
path: "adapter.call",
|
||||
description: "Call an adapter-described tool",
|
||||
@@ -611,7 +611,7 @@ describe("CodeMode schema flexibility", () => {
|
||||
})
|
||||
const runtime = CodeMode.make({ tools: { users: { lookup } } })
|
||||
|
||||
expect(runtime.catalog).toStrictEqual([
|
||||
expect(runtime.catalog()).toStrictEqual([
|
||||
{
|
||||
path: "users.lookup",
|
||||
description: "Look up a user",
|
||||
@@ -631,7 +631,7 @@ describe("CodeMode schema flexibility", () => {
|
||||
execute: () => Effect.succeed("pong"),
|
||||
})
|
||||
const runtime = CodeMode.make({ tools: { net: { ping } } })
|
||||
expect(runtime.catalog[0]?.signature).toBe("tools.net.ping(input: {\n host: string,\n}): Promise<void>")
|
||||
expect(runtime.catalog()[0]?.signature).toBe("tools.net.ping(input: {\n host: string,\n}): Promise<void>")
|
||||
|
||||
const result = await Effect.runPromise(runtime.execute(`return await tools.net.ping({ host: "example.test" })`))
|
||||
expect(result.ok).toBe(true)
|
||||
@@ -684,7 +684,7 @@ describe("CodeMode public contract", () => {
|
||||
|
||||
test("describes the catalog and keeps the search built-in registered", async () => {
|
||||
const runtime = CodeMode.make({ tools })
|
||||
expect(runtime.catalog).toStrictEqual([
|
||||
expect(runtime.catalog()).toStrictEqual([
|
||||
{
|
||||
path: "orders.lookup",
|
||||
description: "Look up an order by ID",
|
||||
@@ -726,8 +726,8 @@ describe("CodeMode public contract", () => {
|
||||
const first = CodeMode.make({ tools: { zeta: { zeta, alpha }, alpha: { zeta, alpha } } })
|
||||
const second = CodeMode.make({ tools: { alpha: { alpha, zeta }, zeta: { alpha, zeta } } })
|
||||
|
||||
expect(first.catalog).toStrictEqual(second.catalog)
|
||||
expect(first.catalog.map((tool) => tool.path)).toEqual(["alpha.alpha", "alpha.zeta", "zeta.alpha", "zeta.zeta"])
|
||||
expect(first.catalog()).toStrictEqual(second.catalog())
|
||||
expect(first.catalog().map((tool) => tool.path)).toEqual(["alpha.alpha", "alpha.zeta", "zeta.alpha", "zeta.zeta"])
|
||||
})
|
||||
|
||||
test("renders bracket notation for tool names that are not JavaScript identifiers", async () => {
|
||||
@@ -739,7 +739,7 @@ describe("CodeMode public contract", () => {
|
||||
})
|
||||
const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } })
|
||||
|
||||
expect(runtime.catalog).toStrictEqual([
|
||||
expect(runtime.catalog()).toStrictEqual([
|
||||
{
|
||||
path: "context7.resolve-library-id",
|
||||
description: "Resolve a library ID",
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
-82
@@ -1,82 +0,0 @@
|
||||
[
|
||||
["", []],
|
||||
["abcd", [105, 183, 29]],
|
||||
[" abcd", [105, 183, 29]],
|
||||
["abcd ", [105, 183, 29]],
|
||||
[" abcd===", null],
|
||||
["abcd=== ", null],
|
||||
["abcd ===", null],
|
||||
["a", null],
|
||||
["ab", [105]],
|
||||
["abc", [105, 183]],
|
||||
["abcde", null],
|
||||
["𐀀", null],
|
||||
["=", null],
|
||||
["==", null],
|
||||
["===", null],
|
||||
["====", null],
|
||||
["=====", null],
|
||||
["a=", null],
|
||||
["a==", null],
|
||||
["a===", null],
|
||||
["a====", null],
|
||||
["a=====", null],
|
||||
["ab=", null],
|
||||
["ab==", [105]],
|
||||
["ab===", null],
|
||||
["ab====", null],
|
||||
["ab=====", null],
|
||||
["abc=", [105, 183]],
|
||||
["abc==", null],
|
||||
["abc===", null],
|
||||
["abc====", null],
|
||||
["abc=====", null],
|
||||
["abcd=", null],
|
||||
["abcd==", null],
|
||||
["abcd===", null],
|
||||
["abcd====", null],
|
||||
["abcd=====", null],
|
||||
["abcde=", null],
|
||||
["abcde==", null],
|
||||
["abcde===", null],
|
||||
["abcde====", null],
|
||||
["abcde=====", null],
|
||||
["=a", null],
|
||||
["=a=", null],
|
||||
["a=b", null],
|
||||
["a=b=", null],
|
||||
["ab=c", null],
|
||||
["ab=c=", null],
|
||||
["abc=d", null],
|
||||
["abc=d=", null],
|
||||
["ab\u000Bcd", null],
|
||||
["ab\u3000cd", null],
|
||||
["ab\u3001cd", null],
|
||||
["ab\tcd", [105, 183, 29]],
|
||||
["ab\ncd", [105, 183, 29]],
|
||||
["ab\fcd", [105, 183, 29]],
|
||||
["ab\rcd", [105, 183, 29]],
|
||||
["ab cd", [105, 183, 29]],
|
||||
["ab\u00a0cd", null],
|
||||
["ab\t\n\f\r cd", [105, 183, 29]],
|
||||
[" \t\n\f\r ab\t\n\f\r cd\t\n\f\r ", [105, 183, 29]],
|
||||
["ab\t\n\f\r =\t\n\f\r =\t\n\f\r ", [105]],
|
||||
["A", null],
|
||||
["/A", [252]],
|
||||
["//A", [255, 240]],
|
||||
["///A", [255, 255, 192]],
|
||||
["////A", null],
|
||||
["/", null],
|
||||
["A/", [3]],
|
||||
["AA/", [0, 15]],
|
||||
["AAAA/", null],
|
||||
["AAA/", [0, 0, 63]],
|
||||
["\u0000nonsense", null],
|
||||
["abcd\u0000nonsense", null],
|
||||
["YQ", [97]],
|
||||
["YR", [97]],
|
||||
["~~", null],
|
||||
["..", null],
|
||||
["--", null],
|
||||
["__", null]
|
||||
]
|
||||
@@ -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,242 +0,0 @@
|
||||
/**
|
||||
* Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75:
|
||||
* - test/language/statements/variable/S12.2_A1.js
|
||||
* - test/language/statements/variable/S12.2_A3.js
|
||||
* - test/language/statements/variable/S12.2_A6_T1.js
|
||||
* - test/language/statements/variable/S12.2_A7.js
|
||||
* - test/language/statements/variable/S12.2_A10.js
|
||||
* - test/language/statements/variable/S12.2_A12.js
|
||||
* - test/language/block-scope/shadowing/hoisting-var-declarations-out-of-blocks.js
|
||||
* - test/language/block-scope/shadowing/catch-parameter-shadowing-var-variable.js
|
||||
* - test/language/statements/for/head-var-bound-names-in-stmt.js
|
||||
* - test/language/statements/function/scope-paramsbody-var-open.js
|
||||
* - test/language/statements/function/scope-paramsbody-var-close.js
|
||||
*
|
||||
* Copyright 2009 the Sputnik authors. All rights reserved.
|
||||
* Copyright (C) 2011, 2016 the V8 project authors. All rights reserved.
|
||||
* Test262 portions are governed by the BSD license in LICENSE.test262.
|
||||
*
|
||||
* Files that observe `var` through `eval`, `this`, `delete`, or the global object (S12.2_A2, A5, A9,
|
||||
* A11, `scope-*-none.js`, `scope-param-elem-*.js`) have no analogue here and are not ported.
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { CodeMode } from "../src/index.js"
|
||||
|
||||
const value = async (code: string) => {
|
||||
const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} }))
|
||||
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
|
||||
return result.value
|
||||
}
|
||||
|
||||
describe("var hoisting Test262 parity", () => {
|
||||
test("test/language/statements/variable/S12.2_A1.js: use before declaration reads undefined", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
__x = __x
|
||||
__y = __x ? "good fellow" : "liar"
|
||||
__z = __z === __x ? 1 : 0
|
||||
let unknown
|
||||
try { __something__undefined = __something__undefined } catch (error) { unknown = error.name }
|
||||
const before = [__y, __z, unknown]
|
||||
var __x, __y = true, __z = __y ? "smeagol" : "golum"
|
||||
return [...before, __y, __z]
|
||||
`),
|
||||
).toEqual(["liar", 1, "ReferenceError", true, "smeagol"])
|
||||
})
|
||||
|
||||
test("test/language/statements/variable/S12.2_A3.js: nested functions redeclare or assign", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
var __var = "OUT"
|
||||
const inner = (function () {
|
||||
var __var = "IN"
|
||||
;(function () { __var = "INNER_SPACE" })()
|
||||
;(function () { var __var = "INNER_SUN" })()
|
||||
return __var
|
||||
})()
|
||||
const after = __var
|
||||
const assigned = (function () {
|
||||
__var = "IN"
|
||||
;(function () { __var = "INNERED" })()
|
||||
;(function () { var __var = "INNAGER" })()
|
||||
return __var
|
||||
})()
|
||||
return [inner, after, assigned, __var]
|
||||
`),
|
||||
).toEqual(["INNER_SPACE", "OUT", "INNERED", "INNERED"])
|
||||
})
|
||||
|
||||
test("test/language/statements/variable/S12.2_A6_T1.js: var inside try and catch is hoisted", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
intry__var = intry__var
|
||||
incatch__var = incatch__var
|
||||
try { var intry__var } catch (e) { var incatch__var }
|
||||
return [typeof intry__var, typeof incatch__var]
|
||||
`),
|
||||
).toEqual(["undefined", "undefined"])
|
||||
})
|
||||
|
||||
test("test/language/statements/variable/S12.2_A7.js: var after break inside for is hoisted", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
infor_var = infor_var
|
||||
for (;;) { break; var infor_var }
|
||||
return typeof infor_var
|
||||
`),
|
||||
).toBe("undefined")
|
||||
})
|
||||
|
||||
test("test/language/statements/variable/S12.2_A10.js: var in for head is hoisted", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
__ind = __ind
|
||||
for (var __ind; ; ) { break }
|
||||
return typeof __ind
|
||||
`),
|
||||
).toBe("undefined")
|
||||
})
|
||||
|
||||
test("test/language/statements/variable/S12.2_A12.js: var in do-while body is hoisted", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
x = x
|
||||
do var x; while (false)
|
||||
return typeof x
|
||||
`),
|
||||
).toBe("undefined")
|
||||
})
|
||||
|
||||
test("test/language/block-scope/shadowing/hoisting-var-declarations-out-of-blocks.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
function fn() {
|
||||
{ var x = 1; var y }
|
||||
return [x, typeof y]
|
||||
}
|
||||
return fn()
|
||||
`),
|
||||
).toEqual([1, "undefined"])
|
||||
})
|
||||
|
||||
test("test/language/block-scope/shadowing/catch-parameter-shadowing-var-variable.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
function fn() {
|
||||
var a = 1
|
||||
let caught
|
||||
try { throw "stuff3" } catch (a) { caught = a }
|
||||
return [caught, a]
|
||||
}
|
||||
return fn()
|
||||
`),
|
||||
).toEqual(["stuff3", 1])
|
||||
})
|
||||
|
||||
test("test/language/statements/for/head-var-bound-names-in-stmt.js: redeclaring the head var in the body", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
var iterCount = 0
|
||||
var first = true
|
||||
for (var x; first; first = false) {
|
||||
var x
|
||||
iterCount += 1
|
||||
}
|
||||
return iterCount
|
||||
`),
|
||||
).toBe(1)
|
||||
})
|
||||
|
||||
test("test/language/statements/function/scope-paramsbody-var-open.js: parameter defaults see the outer var", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
var x = "outside"
|
||||
var probeParams, probeBody
|
||||
function f(_ = probeParams = function () { return x }) {
|
||||
var x = "inside"
|
||||
probeBody = function () { return x }
|
||||
}
|
||||
f()
|
||||
return [probeParams(), probeBody()]
|
||||
`),
|
||||
).toEqual(["outside", "inside"])
|
||||
})
|
||||
|
||||
test("test/language/statements/function/scope-paramsbody-var-close.js: body var does not leak out", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
var probe
|
||||
function f(_ = null) {
|
||||
var x = "inside"
|
||||
probe = function () { return x }
|
||||
}
|
||||
f()
|
||||
var x = "outside"
|
||||
return [probe(), x]
|
||||
`),
|
||||
).toEqual(["inside", "outside"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("var semantics beyond Test262", () => {
|
||||
test("redeclaration and block-level var assign the one function-scoped binding", async () => {
|
||||
expect(await value(`var a = 1; var a = 2; { var a = 3 } return a`)).toBe(3)
|
||||
expect(await value(`var q = 1; { let q = 2 } return q`)).toBe(1)
|
||||
})
|
||||
|
||||
test("var loop counters are shared by closures, let counters are not", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const byVar = []
|
||||
for (var i = 0; i < 3; i++) byVar.push(() => i)
|
||||
const byLet = []
|
||||
for (let j = 0; j < 3; j++) byLet.push(() => j)
|
||||
return [byVar.map((f) => f()), byLet.map((f) => f())]
|
||||
`),
|
||||
).toEqual([
|
||||
[3, 3, 3],
|
||||
[0, 1, 2],
|
||||
])
|
||||
})
|
||||
|
||||
test("for...in and for...of var heads survive the loop", async () => {
|
||||
expect(await value(`for (var k in { a: 1 }) {} for (var [p, q] of [[1, 2]]) {} return [k, p, q]`)).toEqual([
|
||||
"a",
|
||||
1,
|
||||
2,
|
||||
])
|
||||
})
|
||||
|
||||
test("var and function declarations of the same name share a binding", async () => {
|
||||
expect(await value(`var fn = 1; function fn() {} return typeof fn`)).toBe("number")
|
||||
expect(await value(`function fn() {} var fn; return typeof fn`)).toBe("function")
|
||||
expect(await value(`function h() { var fn = 1; function fn() {} return typeof fn } return h()`)).toBe("number")
|
||||
})
|
||||
|
||||
test("a var named after a parameter keeps the argument until assigned", async () => {
|
||||
expect(await value(`function f(a) { var a; return a } return f(7)`)).toBe(7)
|
||||
expect(await value(`function f(a) { var a = 2; return a } return f(7)`)).toBe(2)
|
||||
})
|
||||
|
||||
test("var does not hoist across function boundaries", async () => {
|
||||
expect(await value(`return [typeof b, (() => { var b = 1; return b })()]; var b`)).toEqual(["undefined", 1])
|
||||
expect(
|
||||
await value(
|
||||
`function outer() { var o = 1; function inner() { var o = 2; return o } return [inner(), o] } return outer()`,
|
||||
),
|
||||
).toEqual([2, 1])
|
||||
})
|
||||
|
||||
test("switch cases, labels, and generators hoist var", async () => {
|
||||
expect(await value(`switch (1) { case 1: var s = 9 } label: { var lb = 1 } return [s, lb]`)).toEqual([9, 1])
|
||||
expect(await value(`function* gen() { var t = 1; yield t; var t = 2; yield t } return [...gen()]`)).toEqual([1, 2])
|
||||
})
|
||||
})
|
||||
|
||||
describe("switch case function hoisting", () => {
|
||||
test("function declarations are visible across all cases before their statement runs", async () => {
|
||||
expect(await value(`switch (1) { case 1: return foo(); function foo() { return "hoisted" } }`)).toBe("hoisted")
|
||||
expect(await value(`switch (2) { case 1: function foo() { return "a" } break; case 2: return foo() }`)).toBe("a")
|
||||
})
|
||||
})
|
||||
@@ -1,169 +0,0 @@
|
||||
/**
|
||||
* Portions adapted from web-platform-tests at revision 863077959ca8c1a7ceecfbe2534b75d2527b9013:
|
||||
* - html/webappapis/atob/base64.any.js (btoa reference encoder, input list, and atob WebIDL cases)
|
||||
* - fetch/data-urls/resources/base64.json (copied to fixtures/wpt-base64.json)
|
||||
* - WebCryptoAPI/randomUUID.https.any.js
|
||||
*
|
||||
* Copyright © web-platform-tests contributors. Governed by the 3-Clause BSD license in LICENSE.wpt.
|
||||
*
|
||||
* `assert_throws_dom("InvalidCharacterError", …)` becomes a check on `error.name`: CodeMode has no
|
||||
* DOMException, so the name is carried on a plain Error.
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { CodeMode } from "../src/index.js"
|
||||
|
||||
const base64Cases = (await Bun.file(new URL("./fixtures/wpt-base64.json", import.meta.url)).json()) as Array<
|
||||
[string, Array<number> | null]
|
||||
>
|
||||
|
||||
const value = async (code: string) => {
|
||||
const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} }))
|
||||
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
|
||||
return result.value
|
||||
}
|
||||
|
||||
// The reference encoder from base64.any.js, run inside the interpreter so btoa is checked against
|
||||
// an independent implementation rather than against the host's btoa.
|
||||
const referenceEncoder = `
|
||||
function btoaLookup(idx) {
|
||||
if (idx < 26) return String.fromCharCode(idx + "A".charCodeAt(0))
|
||||
if (idx < 52) return String.fromCharCode(idx - 26 + "a".charCodeAt(0))
|
||||
if (idx < 62) return String.fromCharCode(idx - 52 + "0".charCodeAt(0))
|
||||
if (idx == 62) return "+"
|
||||
if (idx == 63) return "/"
|
||||
}
|
||||
function mybtoa(s) {
|
||||
s = String(s)
|
||||
for (var i = 0; i < s.length; i++) if (s.charCodeAt(i) > 255) return "INVALID_CHARACTER_ERR"
|
||||
var out = ""
|
||||
for (var i = 0; i < s.length; i += 3) {
|
||||
var groupsOfSix = [undefined, undefined, undefined, undefined]
|
||||
groupsOfSix[0] = s.charCodeAt(i) >> 2
|
||||
groupsOfSix[1] = (s.charCodeAt(i) & 0x03) << 4
|
||||
if (s.length > i + 1) {
|
||||
groupsOfSix[1] |= s.charCodeAt(i + 1) >> 4
|
||||
groupsOfSix[2] = (s.charCodeAt(i + 1) & 0x0f) << 2
|
||||
}
|
||||
if (s.length > i + 2) {
|
||||
groupsOfSix[2] |= s.charCodeAt(i + 2) >> 6
|
||||
groupsOfSix[3] = s.charCodeAt(i + 2) & 0x3f
|
||||
}
|
||||
for (var j = 0; j < groupsOfSix.length; j++) {
|
||||
out += typeof groupsOfSix[j] == "undefined" ? "=" : btoaLookup(groupsOfSix[j])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
function testBtoa(input) {
|
||||
var expected = mybtoa(input)
|
||||
if (expected === "INVALID_CHARACTER_ERR") {
|
||||
try { btoa(input) } catch (error) { return error.name === "InvalidCharacterError" ? "ok" : error.name }
|
||||
return "did not throw"
|
||||
}
|
||||
if (btoa(input) !== expected) return "btoa mismatch"
|
||||
if (atob(btoa(input)) !== String(input)) return "roundtrip mismatch"
|
||||
return "ok"
|
||||
}
|
||||
`
|
||||
|
||||
describe("btoa WPT parity (html/webappapis/atob/base64.any.js)", () => {
|
||||
test("every input encodes like the reference encoder and round-trips through atob", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
${referenceEncoder}
|
||||
var tests = ["עברית", "", "ab", "abc", "abcd", "abcde", "\\xff\\xff\\xc0", "\\0a", "a\\0b",
|
||||
undefined, null, 7, 12, 1.5, true, false, NaN, +Infinity, -Infinity, 0, -0]
|
||||
for (var i = 0; i < 258; i++) tests.push(String.fromCharCode(i))
|
||||
tests.push(String.fromCharCode(10000), String.fromCharCode(65534), String.fromCharCode(65535))
|
||||
tests.push(String.fromCharCode(0xd800, 0xdc00))
|
||||
var everything = ""
|
||||
for (var i = 0; i < 256; i++) everything += String.fromCharCode(i)
|
||||
tests.push(everything)
|
||||
return tests.map(testBtoa).filter((outcome) => outcome !== "ok")
|
||||
`),
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("atob WPT parity (fetch/data-urls/resources/base64.json)", () => {
|
||||
const idlCases: Array<[unknown, Array<number> | null]> = [
|
||||
[undefined, null],
|
||||
[null, [158, 233, 101]],
|
||||
[7, null],
|
||||
[12, [215]],
|
||||
[1.5, null],
|
||||
[true, [182, 187]],
|
||||
[false, null],
|
||||
[NaN, [53, 163]],
|
||||
[Infinity, [34, 119, 226, 158, 43, 114]],
|
||||
[-Infinity, null],
|
||||
[0, null],
|
||||
[-0, null],
|
||||
]
|
||||
|
||||
test(`${base64Cases.length} forgiving-base64 inputs decode to the expected bytes or throw InvalidCharacterError`, async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const cases = ${JSON.stringify(base64Cases)}
|
||||
return cases.flatMap(([input, output]) => {
|
||||
try {
|
||||
const result = atob(input)
|
||||
if (output === null) return [[input, "expected throw"]]
|
||||
const bytes = Array.from({ length: result.length }, (_, i) => result.charCodeAt(i))
|
||||
return JSON.stringify(bytes) === JSON.stringify(output) ? [] : [[input, bytes]]
|
||||
} catch (error) {
|
||||
return output === null && error.name === "InvalidCharacterError" ? [] : [[input, error.name]]
|
||||
}
|
||||
})
|
||||
`),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test("WebIDL argument conversion stringifies non-string inputs", async () => {
|
||||
const literal = (input: unknown) =>
|
||||
Object.is(input, -0)
|
||||
? "-0"
|
||||
: typeof input === "number" || input === undefined
|
||||
? String(input)
|
||||
: JSON.stringify(input)
|
||||
expect(
|
||||
await value(`
|
||||
const cases = [${idlCases.map(([input, output]) => `[${literal(input)}, ${JSON.stringify(output)}]`).join(",")}]
|
||||
return cases.flatMap(([input, output]) => {
|
||||
try {
|
||||
const result = atob(input)
|
||||
if (output === null) return [[String(input), "expected throw"]]
|
||||
// The source loop checks only the listed prefix of the decoded bytes.
|
||||
const bytes = output.map((_, i) => result.charCodeAt(i))
|
||||
return JSON.stringify(bytes) === JSON.stringify(output) ? [] : [[String(input), bytes]]
|
||||
} catch (error) {
|
||||
return output === null && error.name === "InvalidCharacterError" ? [] : [[String(input), error.name]]
|
||||
}
|
||||
})
|
||||
`),
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("crypto.randomUUID WPT parity (WebCryptoAPI/randomUUID.https.any.js)", () => {
|
||||
test("namespace format, version, and variant bits over 256 iterations without collision", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const uuids = new Set()
|
||||
const randomUUID = () => {
|
||||
const uuid = crypto.randomUUID()
|
||||
if (uuids.has(uuid)) throw new Error("uuid collision " + uuid)
|
||||
uuids.add(uuid)
|
||||
return uuid
|
||||
}
|
||||
const UUIDRegex = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/
|
||||
let format = true, version = true, variant = true
|
||||
for (let i = 0; i < 256; i++) format = format && UUIDRegex.test(randomUUID())
|
||||
for (let i = 0; i < 256; i++) version = version && (parseInt(randomUUID().split("-")[2].slice(0, 2), 16) & 0b11110000) === 0b01000000
|
||||
for (let i = 0; i < 256; i++) variant = variant && (parseInt(randomUUID().split("-")[3].slice(0, 2), 16) & 0b11000000) === 0b10000000
|
||||
return [format, version, variant, uuids.size]
|
||||
`),
|
||||
).toEqual([true, true, true, 768])
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
+64
-75
@@ -9,7 +9,6 @@ import { AppProcess } from "@opencode/util/process"
|
||||
import { makeGlobalNode } from "@opencode/util/effect/app-node"
|
||||
import { File } from "./file.js"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex.js"
|
||||
import { VcsPatch } from "./vcs/patch.js"
|
||||
|
||||
export class Repository extends Schema.Class<Repository>("Git.Repository")({
|
||||
worktree: AbsolutePath,
|
||||
@@ -309,7 +308,7 @@ const layer = Layer.effect(
|
||||
operationName: OperationError["operation"],
|
||||
repository: Repository,
|
||||
args: string[],
|
||||
options?: { stdin?: string; env?: Record<string, string>; maxOutputBytes?: number },
|
||||
options?: { stdin?: string; env?: Record<string, string> },
|
||||
) {
|
||||
const result = yield* proc
|
||||
.run(
|
||||
@@ -318,7 +317,7 @@ const layer = Layer.effect(
|
||||
env: options?.env,
|
||||
extendEnv: true,
|
||||
}),
|
||||
{ stdin: options?.stdin, maxOutputBytes: options?.maxOutputBytes },
|
||||
{ stdin: options?.stdin },
|
||||
)
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
@@ -332,8 +331,7 @@ const layer = Layer.effect(
|
||||
),
|
||||
)
|
||||
const text = result.stdout.toString("utf8")
|
||||
if (result.exitCode === 0)
|
||||
return { text, stderr: result.stderr.toString("utf8"), truncated: result.stdoutTruncated }
|
||||
if (result.exitCode === 0) return { text, stderr: result.stderr.toString("utf8") }
|
||||
return yield* new OperationError({
|
||||
operation: operationName,
|
||||
directory: repository.worktree,
|
||||
@@ -387,7 +385,9 @@ const layer = Layer.effect(
|
||||
maximumUntrackedFileBytes?: number
|
||||
}) {
|
||||
const list = (args: string[]) =>
|
||||
repositoryOperation("refresh", input.repository, args).pipe(Effect.map((result) => nuls(result.text)))
|
||||
repositoryOperation("refresh", input.repository, args).pipe(
|
||||
Effect.map((result) => result.text.split("\0").filter(Boolean)),
|
||||
)
|
||||
const [tracked, untracked] = yield* Effect.all(
|
||||
[
|
||||
list(["diff-files", "--name-only", "-z", "--", input.scope]),
|
||||
@@ -464,7 +464,13 @@ const layer = Layer.effect(
|
||||
directory: input.repository.worktree,
|
||||
message: result.stderr.toString("utf8").trim() || "Failed to check ignored paths",
|
||||
})
|
||||
return new Set(nuls(result.stdout.toString("utf8")).map((file) => RelativePath.make(file)))
|
||||
return new Set(
|
||||
result.stdout
|
||||
.toString("utf8")
|
||||
.split("\0")
|
||||
.filter(Boolean)
|
||||
.map((file) => RelativePath.make(file)),
|
||||
)
|
||||
})
|
||||
|
||||
const writeTree = Effect.fn("Git.tree.write")(function* (repository: Repository) {
|
||||
@@ -493,23 +499,19 @@ const layer = Layer.effect(
|
||||
to: TreeID
|
||||
}) {
|
||||
// Undo needs both paths of a rename, not only its destination.
|
||||
return nuls(
|
||||
(yield* repositoryOperation("list_files", input.repository, [
|
||||
"diff",
|
||||
"--name-only",
|
||||
"--no-renames",
|
||||
"-z",
|
||||
input.from,
|
||||
input.to,
|
||||
])).text,
|
||||
).map((file) => RelativePath.make(file))
|
||||
return (yield* repositoryOperation("list_files", input.repository, [
|
||||
"diff",
|
||||
"--name-only",
|
||||
"--no-renames",
|
||||
"-z",
|
||||
input.from,
|
||||
input.to,
|
||||
])).text
|
||||
.split("\0")
|
||||
.filter(Boolean)
|
||||
.map((file) => RelativePath.make(file))
|
||||
})
|
||||
|
||||
/**
|
||||
* Three batched invocations over the tree pair instead of three per file. An
|
||||
* explicit empty selection diffs nothing; an absent one diffs every changed path.
|
||||
* Patch output is capped like VCS diffs: files past the cap get an empty patch.
|
||||
*/
|
||||
const treeDiff = Effect.fn("Git.tree.diff")(function* (input: {
|
||||
repository: Repository
|
||||
from: TreeID
|
||||
@@ -517,57 +519,49 @@ const layer = Layer.effect(
|
||||
context?: number
|
||||
paths?: readonly RelativePath[]
|
||||
}) {
|
||||
if (input.paths?.length === 0) return []
|
||||
const args = ["--no-renames", input.from, input.to, "--", ...(input.paths ?? [])]
|
||||
// Patch headers have no -z form: unquoted paths keep chunksByFile matching non-ASCII names.
|
||||
const [names, numbers, patch] = yield* Effect.all(
|
||||
[
|
||||
repositoryOperation("diff", input.repository, ["diff", "--name-status", "-z", ...args]),
|
||||
repositoryOperation("diff", input.repository, ["diff", "--numstat", "-z", ...args]),
|
||||
repositoryOperation(
|
||||
const paths = input.paths ?? (yield* treeFiles(input))
|
||||
return yield* Effect.forEach(paths, (file) =>
|
||||
Effect.gen(function* () {
|
||||
const statusText = (yield* repositoryOperation("diff", input.repository, [
|
||||
"diff",
|
||||
input.repository,
|
||||
["-c", "core.quotepath=false", "diff", "--no-ext-diff", `--unified=${input.context ?? 3}`, ...args],
|
||||
{ maxOutputBytes: VcsPatch.MAX_TOTAL_PATCH_BYTES },
|
||||
),
|
||||
],
|
||||
{ concurrency: 3 },
|
||||
)
|
||||
const statuses = nuls(names.text)
|
||||
const files = statuses.flatMap((code, index) => {
|
||||
const file = statuses[index + 1]
|
||||
if (index % 2 !== 0 || !file) return []
|
||||
return [
|
||||
{
|
||||
file: RelativePath.make(file),
|
||||
status: code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified",
|
||||
} as const,
|
||||
]
|
||||
})
|
||||
const stats = new Map(
|
||||
nuls(numbers.text).flatMap((line) => {
|
||||
const [additions, deletions, ...file] = line.split("\t")
|
||||
if (!additions || !deletions || file.length === 0) return []
|
||||
return [
|
||||
[
|
||||
file.join("\t"),
|
||||
additions === "-" || deletions === "-"
|
||||
? { binary: true, additions: 0, deletions: 0 }
|
||||
: { binary: false, additions: Number(additions), deletions: Number(deletions) },
|
||||
] as const,
|
||||
]
|
||||
"--name-status",
|
||||
"--no-renames",
|
||||
input.from,
|
||||
input.to,
|
||||
"--",
|
||||
file,
|
||||
])).text.trim()
|
||||
const status = statusText.startsWith("A") ? "added" : statusText.startsWith("D") ? "deleted" : "modified"
|
||||
const stats = (yield* repositoryOperation("diff", input.repository, [
|
||||
"diff",
|
||||
"--numstat",
|
||||
"--no-renames",
|
||||
input.from,
|
||||
input.to,
|
||||
"--",
|
||||
file,
|
||||
])).text.split("\t")
|
||||
const binary = stats[0] === "-" || stats[1] === "-"
|
||||
const patch = binary
|
||||
? ""
|
||||
: (yield* repositoryOperation("diff", input.repository, [
|
||||
"diff",
|
||||
`--unified=${input.context ?? 3}`,
|
||||
"--no-renames",
|
||||
input.from,
|
||||
input.to,
|
||||
"--",
|
||||
file,
|
||||
])).text
|
||||
return {
|
||||
file,
|
||||
status,
|
||||
additions: binary ? 0 : Number(stats[0] ?? 0),
|
||||
deletions: binary ? 0 : Number(stats[1] ?? 0),
|
||||
patch,
|
||||
} satisfies File.Diff
|
||||
}),
|
||||
)
|
||||
const patches = VcsPatch.chunksByFile(patch, (index) => files[index]?.file)
|
||||
return files.map((entry) => {
|
||||
const stat = stats.get(entry.file)
|
||||
return {
|
||||
...entry,
|
||||
additions: stat?.additions ?? 0,
|
||||
deletions: stat?.deletions ?? 0,
|
||||
patch: stat?.binary ? "" : (patches.get(entry.file) ?? VcsPatch.emptyPatch(entry.file)),
|
||||
} satisfies File.Diff
|
||||
})
|
||||
})
|
||||
|
||||
const hasEntry = Effect.fnUntraced(function* (repository: Repository, tree: TreeID, file: RelativePath) {
|
||||
@@ -739,11 +733,6 @@ function execute(cwd: string, proc: AppProcess.Interface, args: string[]) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Split NUL-terminated git output into its records. */
|
||||
function nuls(text: string) {
|
||||
return text.split("\0").filter(Boolean)
|
||||
}
|
||||
|
||||
function resolvePath(cwd: string, value: string) {
|
||||
const trimmed = value.replace(/[\r\n]+$/, "")
|
||||
if (!trimmed) return cwd
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -54,11 +54,8 @@ import { SessionModelTransport } from "./session/model-transport.js"
|
||||
import { llmClient } from "./effect/app-node-platform.js"
|
||||
import { Snapshot } from "./snapshot.js"
|
||||
import { Session } from "./session/session.js"
|
||||
import { SessionDiff, TurnRangeError } from "./session/diff.js"
|
||||
import { LocationServiceMap } from "./location-service-map.js"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import type { EventLog } from "@opencode/schema/event-log"
|
||||
import type { FileDiff } from "@opencode/schema/file-diff"
|
||||
import { Job } from "./job.js"
|
||||
import type { Command } from "./command.js"
|
||||
import { SessionEnvironment } from "./session/environment.js"
|
||||
@@ -110,7 +107,6 @@ export {
|
||||
type InboxItemRef = { readonly sessionID: SessionSchema.ID; readonly inboxID: SessionMessage.ID }
|
||||
|
||||
export { DestinationNotFoundError, DestinationNotDirectoryError, DestinationUnavailableError }
|
||||
export { TurnRangeError }
|
||||
|
||||
export interface Interface {
|
||||
readonly list: (input?: ListInput) => Effect.Effect<{
|
||||
@@ -137,13 +133,6 @@ export interface Interface {
|
||||
readonly context: (
|
||||
sessionID: SessionSchema.ID,
|
||||
) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
|
||||
/** Structured diffs of the files changed by a turn or range of turns; see `SessionDiff.turn`. */
|
||||
readonly diff: (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly messageID?: SessionMessage.ID
|
||||
readonly to?: SessionMessage.ID
|
||||
readonly context?: number
|
||||
}) => Effect.Effect<readonly FileDiff.Info[], NotFoundError | MessageNotFoundError | TurnRangeError | Snapshot.Error>
|
||||
/**
|
||||
* Durable admitted session work not yet visible in projected history,
|
||||
* ordered by admission. Includes unpromoted user and synthetic inputs and
|
||||
@@ -232,7 +221,6 @@ const layer = Layer.effect(
|
||||
const moves = yield* SessionMove.Service
|
||||
const jobs = yield* Job.Service
|
||||
const environments = yield* SessionEnvironment.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const sessions = yield* Session.make()
|
||||
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
||||
|
||||
@@ -364,17 +352,6 @@ const layer = Layer.effect(
|
||||
yield* result.get(sessionID)
|
||||
return yield* store.context(sessionID)
|
||||
}),
|
||||
diff: Effect.fn("Session.diff")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
const active = yield* execution.isActive(input.sessionID)
|
||||
return yield* SessionDiff.turn(db, locations, {
|
||||
session,
|
||||
active,
|
||||
messageID: input.messageID,
|
||||
to: input.to,
|
||||
context: input.context,
|
||||
})
|
||||
}),
|
||||
inbox: (sessionID) => sessions.forSession(sessionID).inbox(),
|
||||
cancelInbox: (input) => sessions.forSession(input.sessionID).cancelInbox(input.inboxID),
|
||||
steerInbox: (input) => sessions.forSession(input.sessionID).steerInbox(input.inboxID),
|
||||
@@ -463,7 +440,6 @@ export const node: LayerNode.Provider<Service, never, typeof Node.tags.values.gl
|
||||
SessionInbox.node,
|
||||
SessionMove.node,
|
||||
SessionProjector.node,
|
||||
LocationServiceMap.node,
|
||||
FSUtil.node,
|
||||
App.node,
|
||||
],
|
||||
|
||||
@@ -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.",
|
||||
),
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
export * as SessionDiff from "./diff.js"
|
||||
|
||||
import { and, asc, eq, gt, inArray, lt, or, sql } from "drizzle-orm"
|
||||
import { Context, Effect, Schema } from "effect"
|
||||
import { Location } from "@opencode/schema/location"
|
||||
import { Database } from "../database/database.js"
|
||||
import { LocationServiceMap } from "../location-service-map.js"
|
||||
import { Snapshot } from "../snapshot.js"
|
||||
import { PATCH_CONTEXT_LINES } from "../vcs/patch.js"
|
||||
import { MessageNotFoundError } from "./error.js"
|
||||
import { SessionMessage } from "./message.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import { SessionMessageTable } from "./sql.js"
|
||||
|
||||
export class TurnRangeError extends Schema.TaggedError<TurnRangeError>()("Session.TurnRangeError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
field: Schema.Literals(["messageID", "to"]),
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
const decodeLocation = Schema.decodeUnknownSync(Schema.fromJsonString(Location.Ref))
|
||||
|
||||
/**
|
||||
* Diff the files changed by the turn containing a user message. A turn runs from
|
||||
* the first prompt after the Session was last idle until the next idle marker, so
|
||||
* prompts steered in while it was busy belong to the same turn; `to` extends the
|
||||
* range through the turn containing a later user message. Compares the range's
|
||||
* first recorded start snapshot with its last recorded end snapshot; only a step
|
||||
* still running in the active Session compares against the working copy. Like VCS
|
||||
* diffs, an omitted `context` yields full-file patches.
|
||||
*
|
||||
* A Session without any idle marker predates them, so its prompts span until the
|
||||
* next user message instead.
|
||||
*
|
||||
* Snapshot trees live in the repository of the Location that captured them, so a
|
||||
* range spanning a location switch is rejected rather than diffed wrongly.
|
||||
*/
|
||||
export const turn = Effect.fn("SessionDiff.turn")(function* (
|
||||
db: Database.Interface["db"],
|
||||
locations: Context.Service.Shape<typeof LocationServiceMap.Service>,
|
||||
input: {
|
||||
readonly session: SessionSchema.Info
|
||||
/** The process is currently executing this Session. */
|
||||
readonly active: boolean
|
||||
readonly messageID?: SessionMessage.ID
|
||||
readonly to?: SessionMessage.ID
|
||||
readonly context?: number
|
||||
},
|
||||
) {
|
||||
const sessionID = input.session.id
|
||||
const rows = yield* db
|
||||
.select({ id: SessionMessageTable.id, type: SessionMessageTable.type, seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, sessionID),
|
||||
or(
|
||||
inArray(SessionMessageTable.type, ["user", "idle"]),
|
||||
input.messageID ? eq(SessionMessageTable.id, input.messageID) : undefined,
|
||||
input.to ? eq(SessionMessageTable.id, input.to) : undefined,
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const users = rows.filter((row) => row.type === "user")
|
||||
const markers = rows.filter((row) => row.type === "idle")
|
||||
const resolve = Effect.fn(function* (field: "messageID" | "to", id: SessionMessage.ID) {
|
||||
const row = rows.find((row) => row.id === id)
|
||||
if (!row) return yield* new MessageNotFoundError({ sessionID, messageID: id })
|
||||
if (row.type !== "user")
|
||||
return yield* new TurnRangeError({ sessionID, field, message: `Message ${id} is not a user message` })
|
||||
return row
|
||||
})
|
||||
const anchor = input.messageID ? yield* resolve("messageID", input.messageID) : users[users.length - 1]
|
||||
if (!anchor) return []
|
||||
const last = input.to ? yield* resolve("to", input.to) : anchor
|
||||
if (last.seq < anchor.seq)
|
||||
return yield* new TurnRangeError({ sessionID, field: "to", message: `Message ${last.id} precedes ${anchor.id}` })
|
||||
// Without any marker, history predates idle markers and a prompt's turn ends at the next prompt.
|
||||
const legacy = markers.length === 0
|
||||
// The turn opens with the first prompt after the previous idle marker; the anchor itself is the latest candidate.
|
||||
const opened = markers.findLast((row) => row.seq < anchor.seq)?.seq ?? -1
|
||||
const start = legacy ? anchor.seq : (users.find((row) => row.seq > opened)?.seq ?? anchor.seq)
|
||||
const end = legacy ? users.find((row) => row.seq > last.seq)?.seq : markers.find((row) => row.seq > last.seq)?.seq
|
||||
const steps = yield* db
|
||||
.select({
|
||||
seq: SessionMessageTable.seq,
|
||||
start: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.snapshot.start')`,
|
||||
end: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.snapshot.end')`,
|
||||
completed: sql<number | null>`json_extract(${SessionMessageTable.data}, '$.time.completed')`,
|
||||
})
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, sessionID),
|
||||
eq(SessionMessageTable.type, "assistant"),
|
||||
gt(SessionMessageTable.seq, start),
|
||||
end === undefined ? undefined : lt(SessionMessageTable.seq, end),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const first = steps[0]
|
||||
const final = steps[steps.length - 1]
|
||||
const from = steps.find((step) => step.start)?.start
|
||||
if (!first || !final || !from) return []
|
||||
const switches = yield* db
|
||||
.select({
|
||||
seq: SessionMessageTable.seq,
|
||||
location: sql<string>`json_extract(${SessionMessageTable.data}, '$.location')`,
|
||||
previous: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.previous.location')`,
|
||||
})
|
||||
.from(SessionMessageTable)
|
||||
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "location-switched")))
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
if (switches.some((row) => row.seq > first.seq && row.seq < final.seq))
|
||||
return yield* new TurnRangeError({ sessionID, field: "to", message: "Turn range spans a location change" })
|
||||
const before = switches.findLast((row) => row.seq < first.seq)?.location
|
||||
const after = switches.find((row) => row.seq > first.seq)?.previous
|
||||
const location = before ? decodeLocation(before) : after ? decodeLocation(after) : input.session.location
|
||||
const recorded = steps.findLast((step) => step.end)?.end
|
||||
return yield* Effect.gen(function* () {
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const running = input.active && final.completed === null
|
||||
const to = running ? ((yield* snapshot.capture()) ?? recorded) : recorded
|
||||
if (!to) return []
|
||||
return yield* snapshot.diff({
|
||||
from: Snapshot.ID.make(from),
|
||||
to: Snapshot.ID.make(to),
|
||||
context: input.context ?? PATCH_CONTEXT_LINES,
|
||||
})
|
||||
}).pipe(Effect.provide(locations.get(location)))
|
||||
})
|
||||
@@ -60,21 +60,6 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
)
|
||||
})
|
||||
|
||||
const idle = (outcome: SessionMessage.Idle["outcome"]) =>
|
||||
clearCurrentRetry.pipe(
|
||||
Effect.andThen(
|
||||
adapter.appendMessage(
|
||||
SessionMessage.Idle.make({
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
type: "idle",
|
||||
outcome,
|
||||
metadata: event.metadata,
|
||||
time: { created },
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const project = pipe(
|
||||
Match.type<SessionEvent.DurableEvent>(),
|
||||
Match.discriminatorsExhaustive("type")({
|
||||
@@ -138,11 +123,9 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
"session.inbox.cancelled": () => Effect.void,
|
||||
"session.inbox.delivery.changed": () => Effect.void,
|
||||
"session.execution.started": () => Effect.void,
|
||||
"session.execution.succeeded": () => idle("succeeded"),
|
||||
"session.execution.failed": () => idle("failed"),
|
||||
// Shutdown keeps the execution claim and the resumed drain continues the turn.
|
||||
"session.execution.interrupted": (event) =>
|
||||
event.data.reason === "shutdown" ? clearCurrentRetry : idle("interrupted"),
|
||||
"session.execution.succeeded": () => clearCurrentRetry,
|
||||
"session.execution.failed": () => clearCurrentRetry,
|
||||
"session.execution.interrupted": () => clearCurrentRetry,
|
||||
"session.instructions.updated": (event) => {
|
||||
if (event.data.text === undefined) return Effect.void
|
||||
return adapter.appendMessage(
|
||||
@@ -426,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>>
|
||||
}
|
||||
|
||||
@@ -349,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)),
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -226,7 +226,6 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
|
||||
switch (message.type) {
|
||||
case "agent-switched":
|
||||
case "model-switched":
|
||||
case "idle":
|
||||
return []
|
||||
case "location-switched":
|
||||
return [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -131,55 +131,38 @@ const layer = Layer.effect(
|
||||
)
|
||||
})
|
||||
|
||||
const comparison = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
|
||||
const compare = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
|
||||
const repo = yield* repository.pipe(Effect.mapError((cause) => failure(operation, cause)))
|
||||
return {
|
||||
source: repo.source,
|
||||
const comparison = {
|
||||
repository: repo.snapshotRepository,
|
||||
from: Git.TreeID.make(input.from),
|
||||
to: Git.TreeID.make(input.to),
|
||||
}
|
||||
})
|
||||
|
||||
// Snapshots track every scoped file; the source repository's ignore rules decide what callers see.
|
||||
const ignored = Effect.fnUntraced(function* (
|
||||
operation: "files" | "diff",
|
||||
source: Git.Repository,
|
||||
paths: readonly RelativePath[],
|
||||
) {
|
||||
return yield* git.index
|
||||
.ignored({ repository: source, paths })
|
||||
const files = yield* git.tree.files(comparison).pipe(Effect.mapError((cause) => failure(operation, cause)))
|
||||
const ignored = yield* git.index
|
||||
.ignored({ repository: repo.source, paths: files })
|
||||
.pipe(Effect.mapError((cause) => failure(operation, cause)))
|
||||
return {
|
||||
input: comparison,
|
||||
files,
|
||||
ignored,
|
||||
}
|
||||
})
|
||||
|
||||
const files = Effect.fn("Snapshot.files")(function* (input: CompareInput) {
|
||||
const compared = yield* comparison("files", input)
|
||||
const changed = yield* git.tree
|
||||
.files({ repository: compared.repository, from: compared.from, to: compared.to })
|
||||
.pipe(Effect.mapError((cause) => failure("files", cause)))
|
||||
const skipped = yield* ignored("files", compared.source, changed)
|
||||
return changed.filter((file) => !skipped.has(file))
|
||||
const comparison = yield* compare("files", input)
|
||||
return comparison.files.filter((file) => !comparison.ignored.has(file))
|
||||
})
|
||||
|
||||
const diff = Effect.fn("Snapshot.diff")(function* (input: DiffInput) {
|
||||
if (input.paths?.length === 0) return []
|
||||
const compared = yield* comparison("diff", input)
|
||||
// Only an explicit selection becomes a pathspec; ignored paths are dropped from the result instead.
|
||||
const diffs = yield* git.tree
|
||||
const comparison = yield* compare("diff", input)
|
||||
return yield* git.tree
|
||||
.diff({
|
||||
repository: compared.repository,
|
||||
from: compared.from,
|
||||
to: compared.to,
|
||||
...comparison.input,
|
||||
context: input.context,
|
||||
paths: input.paths,
|
||||
paths: (input.paths ?? comparison.files).filter((file) => !comparison.ignored.has(file)),
|
||||
})
|
||||
.pipe(Effect.mapError((cause) => failure("diff", cause)))
|
||||
const skipped = yield* ignored(
|
||||
"diff",
|
||||
compared.source,
|
||||
diffs.map((file) => RelativePath.make(file.file)),
|
||||
)
|
||||
return diffs.filter((file) => !skipped.has(RelativePath.make(file.file)))
|
||||
})
|
||||
|
||||
const plan = Effect.fnUntraced(function* (worktree: AbsolutePath, input: RestoreInput) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Effect } from "effect"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { Git } from "@opencode/core/git"
|
||||
import { AbsolutePath, RelativePath } from "@opencode/core/schema"
|
||||
import { VcsPatch } from "@opencode/core/vcs/patch"
|
||||
import { branch, commit, initRepo, read, withRemote } from "./fixture/git"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
@@ -197,42 +196,6 @@ describe("Git trees", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("caps batched tree patches, keeps per-file stats past the cap, and matches non-ASCII names", () =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(() => initRepo(root.path))
|
||||
const git = yield* Git.Service
|
||||
const repository = yield* git.repo.discover(AbsolutePath.make(root.path))
|
||||
if (!repository) throw new Error("Repository not found")
|
||||
const before = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
|
||||
const lines = Math.ceil(VcsPatch.MAX_TOTAL_PATCH_BYTES / 80) + 1
|
||||
yield* Effect.promise(async () => {
|
||||
await Bun.write(path.join(root.path, "a-small.txt"), "small\n")
|
||||
await Bun.write(path.join(root.path, "b-large.txt"), `${"x".repeat(79)}\n`.repeat(lines))
|
||||
await Bun.write(path.join(root.path, "c-binary.bin"), new Uint8Array([0, 1, 2, 3]))
|
||||
await Bun.write(path.join(root.path, "a-caf\u00e9.txt"), "caf\u00e9\n")
|
||||
})
|
||||
const after = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
|
||||
|
||||
const diffs = yield* git.tree.diff({ repository, from: before, to: after, context: 0 })
|
||||
expect(diffs.map((item) => [item.file, item.status, item.additions, item.deletions])).toEqual([
|
||||
["a-caf\u00e9.txt", "added", 1, 0],
|
||||
["a-small.txt", "added", 1, 0],
|
||||
["b-large.txt", "added", lines, 0],
|
||||
["c-binary.bin", "added", 0, 0],
|
||||
])
|
||||
// Patch headers are not NUL-delimited; a quoted (octal-escaped) header would orphan this chunk.
|
||||
expect(diffs[0]?.patch).toContain("+caf\u00e9\n")
|
||||
expect(diffs[1]?.patch).toContain("+small\n")
|
||||
expect(diffs[2]?.patch).toBe(VcsPatch.emptyPatch("b-large.txt"))
|
||||
expect(diffs[3]?.patch).toBe("")
|
||||
expect(yield* git.tree.diff({ repository, from: before, to: after, paths: [] })).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("captures, compares, previews, and restores scoped trees", () =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* Effect.acquireRelease(
|
||||
|
||||
@@ -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 })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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,198 +0,0 @@
|
||||
import { $ } from "bun"
|
||||
import { describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Agent } from "@opencode/core/agent"
|
||||
import { Bus } from "@opencode/core/bus"
|
||||
import { Database } from "@opencode/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
||||
import { LocationServiceMap } from "@opencode/core/location-service-map"
|
||||
import { Model } from "@opencode/core/model"
|
||||
import { Plugin } from "@opencode/core/plugin"
|
||||
import { Provider } from "@opencode/core/provider"
|
||||
import { AbsolutePath } from "@opencode/core/schema"
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { SessionDiff } from "@opencode/core/session/diff"
|
||||
import { SessionEvent } from "@opencode/core/session/event"
|
||||
import { SessionExecution } from "@opencode/core/session/execution"
|
||||
import { SessionInbox } from "@opencode/core/session/inbox"
|
||||
import { SessionMessage } from "@opencode/core/session/message"
|
||||
import { SessionProjector } from "@opencode/core/session/projector"
|
||||
import { Snapshot } from "@opencode/core/snapshot"
|
||||
import { Money } from "@opencode/schema/money"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { offlineModels } from "./fixture/models"
|
||||
import { tmpdirScoped } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, Session.node, LocationServiceMap.node]),
|
||||
[Global.node.replace(tempGlobalLayer), SessionExecution.node.replace(SessionExecution.noopLayer), offlineModels],
|
||||
),
|
||||
)
|
||||
|
||||
const summarize = (file: { file: string; status: string; additions: number; deletions: number }) => [
|
||||
file.file,
|
||||
file.status,
|
||||
file.additions,
|
||||
file.deletions,
|
||||
]
|
||||
|
||||
describe("Session.diff", () => {
|
||||
it.live(
|
||||
"diffs the busy period containing a user message and ranges across later turns",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
const directory = path.join(tmp.path, "project")
|
||||
const write = (name: string, content: string) => () => Bun.write(path.join(directory, name), content)
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(directory)
|
||||
await write("first.txt", "first\n")()
|
||||
await write("second.txt", "second\n")()
|
||||
await write("manual.txt", "manual\n")()
|
||||
await $`git init -q`.cwd(directory).quiet()
|
||||
await $`git -c core.fsmonitor=false add .`.cwd(directory).quiet()
|
||||
})
|
||||
const sessions = yield* Session.Service
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const created = yield* sessions.create({ location: { directory: AbsolutePath.make(directory) } })
|
||||
const diff = (input?: { messageID?: SessionMessage.ID; to?: SessionMessage.ID }) =>
|
||||
sessions
|
||||
.diff({ sessionID: created.id, context: 0, ...input })
|
||||
.pipe(Effect.map((files) => files.map(summarize)))
|
||||
expect(yield* diff()).toEqual([])
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
yield* plugins.awaitActivation
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const usage = {
|
||||
cost: Money.USD.zero,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
}
|
||||
const prompt = Effect.fn(function* (text: string) {
|
||||
const admitted = yield* sessions.prompt({ sessionID: created.id, text, resume: false })
|
||||
yield* SessionInbox.promote(database.db, bus, created.id, "steer")
|
||||
return admitted.id
|
||||
})
|
||||
const step = Effect.fn(function* (edit: () => Promise<unknown>, end: "recorded" | "unrecorded" | "running") {
|
||||
const before = yield* snapshot.capture()
|
||||
if (!before) throw new Error("Start snapshot missing")
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
yield* bus.publish(SessionEvent.Step.Started, {
|
||||
sessionID: created.id,
|
||||
assistantMessageID,
|
||||
agent: Agent.defaultID,
|
||||
model: { id: Model.ID.make("test-model"), providerID: Provider.ID.make("test-provider") },
|
||||
snapshot: before,
|
||||
})
|
||||
yield* Effect.promise(edit)
|
||||
if (end === "running") return assistantMessageID
|
||||
const after = end === "recorded" ? yield* snapshot.capture() : undefined
|
||||
yield* bus.publish(SessionEvent.Step.Ended, {
|
||||
sessionID: created.id,
|
||||
assistantMessageID,
|
||||
finish: "stop",
|
||||
...usage,
|
||||
snapshot: after,
|
||||
files: after && before ? yield* snapshot.files({ from: before, to: after }) : undefined,
|
||||
})
|
||||
return assistantMessageID
|
||||
})
|
||||
|
||||
const idle = (outcome: "succeeded" | "failed") =>
|
||||
outcome === "succeeded"
|
||||
? bus.publish(SessionEvent.Execution.Succeeded, { sessionID: created.id })
|
||||
: bus.publish(SessionEvent.Execution.Failed, {
|
||||
sessionID: created.id,
|
||||
error: { type: "unknown", message: "failed" },
|
||||
})
|
||||
|
||||
// Before any idle marker exists, a prompt's turn ends at the next prompt.
|
||||
const first = yield* prompt("Edit the first file")
|
||||
const firstStep = yield* step(write("first.txt", "first edited\n"), "recorded")
|
||||
// Edits made while idle are not a turn's work, but a range spanning them still sees them.
|
||||
yield* Effect.promise(write("manual.txt", "manual edited\n"))
|
||||
const second = yield* prompt("Edit the second file")
|
||||
yield* step(write("second.txt", "second edited\n"), "recorded")
|
||||
expect(yield* diff()).toEqual([["second.txt", "modified", 1, 1]])
|
||||
expect(yield* diff({ messageID: first })).toEqual([["first.txt", "modified", 1, 1]])
|
||||
|
||||
// Once markers exist, a turn spans a whole busy period, steers included; earlier history merges into the first one.
|
||||
yield* idle("succeeded")
|
||||
const third = yield* prompt("Add a third file")
|
||||
yield* step(write("third.txt", "third\n"), "recorded")
|
||||
const steer = yield* prompt("Also add a fourth file")
|
||||
yield* step(write("fourth.txt", "fourth\n"), "recorded")
|
||||
yield* idle("failed")
|
||||
const busy = [
|
||||
["fourth.txt", "added", 1, 0],
|
||||
["third.txt", "added", 1, 0],
|
||||
]
|
||||
expect(yield* diff()).toEqual(busy)
|
||||
expect(yield* diff({ messageID: steer })).toEqual(busy)
|
||||
expect(yield* diff({ messageID: second })).toEqual([
|
||||
["first.txt", "modified", 1, 1],
|
||||
["manual.txt", "modified", 1, 1],
|
||||
["second.txt", "modified", 1, 1],
|
||||
])
|
||||
expect(yield* diff({ messageID: first, to: third })).toEqual([
|
||||
["first.txt", "modified", 1, 1],
|
||||
["fourth.txt", "added", 1, 0],
|
||||
["manual.txt", "modified", 1, 1],
|
||||
["second.txt", "modified", 1, 1],
|
||||
["third.txt", "added", 1, 0],
|
||||
])
|
||||
const full = yield* sessions.diff({ sessionID: created.id, messageID: first })
|
||||
expect(full[0]?.patch).toContain("-first\n+first edited\n")
|
||||
expect(yield* diff({ messageID: steer, to: second }).pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "Session.TurnRangeError",
|
||||
field: "to",
|
||||
})
|
||||
expect(yield* diff({ messageID: firstStep }).pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "Session.TurnRangeError",
|
||||
field: "messageID",
|
||||
})
|
||||
expect(yield* diff({ messageID: SessionMessage.ID.create() }).pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "Session.MessageNotFoundError",
|
||||
})
|
||||
|
||||
// A completed step without an end snapshot falls back to the last recorded end.
|
||||
yield* prompt("Edit both files again")
|
||||
yield* step(write("first.txt", "first edited twice\n"), "recorded")
|
||||
yield* step(write("second.txt", "second edited twice\n"), "unrecorded")
|
||||
yield* idle("succeeded")
|
||||
expect(yield* diff()).toEqual([["first.txt", "modified", 1, 1]])
|
||||
|
||||
// Only a step still running in the active session compares against the working copy.
|
||||
yield* prompt("Delete the manual file")
|
||||
yield* step(() => fs.rm(path.join(directory, "manual.txt")), "running")
|
||||
expect(yield* diff()).toEqual([])
|
||||
const session = yield* sessions.get(created.id)
|
||||
const live = yield* SessionDiff.turn(database.db, locations, { session, active: true, context: 0 })
|
||||
expect(live.map(summarize)).toEqual([["manual.txt", "deleted", 0, 1]])
|
||||
|
||||
// Reverting removes later history, markers included; a fork keeps the copied turns.
|
||||
yield* sessions.revert.stage({ sessionID: created.id, messageID: steer, files: false })
|
||||
yield* sessions.revert.commit(created.id)
|
||||
expect(yield* diff()).toEqual([["third.txt", "added", 1, 0]])
|
||||
expect(yield* diff({ messageID: steer }).pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "Session.MessageNotFoundError",
|
||||
})
|
||||
const forked = yield* sessions.fork({ sessionID: created.id, boundary: { type: "through" } })
|
||||
expect((yield* sessions.diff({ sessionID: forked.id, context: 0 })).map(summarize)).toEqual([
|
||||
["third.txt", "added", 1, 0],
|
||||
])
|
||||
}).pipe(Effect.provide(LocationServiceMap.Service.get(created.location)))
|
||||
}),
|
||||
// Real Location/plugin startup and Git snapshots can exceed five seconds under CI load.
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
})
|
||||
@@ -561,9 +561,7 @@ describe("SessionRestart background recovery", () => {
|
||||
expect(yield* restarted.pendingBackground).toEqual([])
|
||||
expect(yield* SessionInbox.list(database.db, sessionID)).toHaveLength(delivered ? 0 : 1)
|
||||
yield* SessionInbox.promote(database.db, bus, sessionID, "steer")
|
||||
// Recovery ends a busy period, so an idle marker follows the notification.
|
||||
const messages = (yield* sessions.messages({ sessionID })).filter((message) => message.type !== "idle")
|
||||
expect(messages).toMatchObject([
|
||||
expect(yield* sessions.messages({ sessionID })).toMatchObject([
|
||||
{
|
||||
id: background.notificationID,
|
||||
type: "synthetic",
|
||||
@@ -571,6 +569,7 @@ describe("SessionRestart background recovery", () => {
|
||||
metadata: { state: "completed" },
|
||||
},
|
||||
])
|
||||
expect(yield* sessions.messages({ sessionID })).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,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",
|
||||
() =>
|
||||
|
||||
@@ -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
|
||||
@@ -100,8 +85,6 @@ 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
|
||||
|
||||
@@ -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
|
||||
@@ -100,8 +85,6 @@ 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
|
||||
|
||||
@@ -3242,152 +3242,6 @@
|
||||
"summary": "Get session context"
|
||||
}
|
||||
},
|
||||
"/api/session/{sessionID}/diff": {
|
||||
"get": {
|
||||
"tags": ["session"],
|
||||
"operationId": "v2.session.diff",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"pattern": "^ses"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "messageID",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^msg_"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "User message whose turn to diff. Defaults to the turn of the newest user message."
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "to",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^msg_"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Later user message whose turn ends the range. Defaults to the turn of `messageID` alone."
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "context",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Unchanged lines around each hunk. Omit for full-file patches."
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/FileDiff.Info"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "MessageNotFoundError | SessionNotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/MessageNotFoundErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "UnknownError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnknownErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Structured per-file diffs of the files a turn changed. A turn runs from the first prompt after the session was last idle until its next idle marker, so prompts steered in while it was busy belong to the same turn; `to` extends the range through a later turn. Compares the range's first recorded snapshot with its last; a step still running in the active session compares against the working copy. Ranges that span a location change are rejected. In sessions without any idle marker, a prompt's turn spans until the next user message.",
|
||||
"summary": "Diff session turns"
|
||||
}
|
||||
},
|
||||
"/api/session/{sessionID}/inbox": {
|
||||
"get": {
|
||||
"tags": ["session"],
|
||||
@@ -4636,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": [],
|
||||
@@ -4726,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"
|
||||
}
|
||||
},
|
||||
@@ -14417,9 +14243,6 @@
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"websocket": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"modelID": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -14528,9 +14351,6 @@
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"websocket": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"canonical": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -16477,9 +16297,6 @@
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"websocket": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"settings": {
|
||||
"type": "object"
|
||||
},
|
||||
@@ -17468,9 +17285,6 @@
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"websocket": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"settings": {
|
||||
"type": "object"
|
||||
},
|
||||
@@ -18442,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"],
|
||||
@@ -18487,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"],
|
||||
@@ -18540,38 +18342,6 @@
|
||||
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Session.Message.Idle": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^msg_"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
"time": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": ["created"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["idle"]
|
||||
},
|
||||
"outcome": {
|
||||
"type": "string",
|
||||
"enum": ["succeeded", "failed", "interrupted"]
|
||||
}
|
||||
},
|
||||
"required": ["id", "time", "type", "outcome"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Session.Message.Info": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -18603,9 +18373,6 @@
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Session.Message.Compaction"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Session.Message.Idle"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -30,7 +30,6 @@ import { Model } from "@opencode/schema/model"
|
||||
import { Location } from "@opencode/schema/location"
|
||||
import { SessionEvent } from "@opencode/schema/session-event"
|
||||
import { EventLog } from "@opencode/schema/event-log"
|
||||
import { FileDiff } from "@opencode/schema/file-diff"
|
||||
|
||||
const ParentIDFilter = Schema.Union([
|
||||
Session.ID,
|
||||
@@ -522,31 +521,6 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("session.diff", "/api/session/:sessionID/diff", {
|
||||
params: { sessionID: Session.ID },
|
||||
query: Schema.Struct({
|
||||
messageID: Schema.optional(SessionMessage.ID).annotate({
|
||||
description: "User message whose turn to diff. Defaults to the turn of the newest user message.",
|
||||
}),
|
||||
to: Schema.optional(SessionMessage.ID).annotate({
|
||||
description: "Later user message whose turn ends the range. Defaults to the turn of `messageID` alone.",
|
||||
}),
|
||||
context: Schema.NumberFromString.pipe(Schema.decodeTo(NonNegativeInt), Schema.optional).annotate({
|
||||
description: "Unchanged lines around each hunk. Omit for full-file patches.",
|
||||
}),
|
||||
}),
|
||||
success: Schema.Struct({ data: Schema.Array(FileDiff.Info) }),
|
||||
error: [InvalidRequestError, MessageNotFoundError, SessionNotFoundError, UnknownError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.diff",
|
||||
summary: "Diff session turns",
|
||||
description:
|
||||
"Structured per-file diffs of the files a turn changed. A turn runs from the first prompt after the session was last idle until its next idle marker, so prompts steered in while it was busy belong to the same turn; `to` extends the range through a later turn. Compares the range's first recorded snapshot with its last; a step still running in the active session compares against the working copy. Ranges that span a location change are rejected. In sessions without any idle marker, a prompt's turn spans until the next user message.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("session.inbox.list", "/api/session/:sessionID/inbox", {
|
||||
params: { sessionID: Session.ID },
|
||||
|
||||
@@ -280,18 +280,6 @@ export const Compaction = Schema.Union([CompactionRunning, CompactionCompleted,
|
||||
)
|
||||
export type Compaction = CompactionRunning | CompactionCompleted | CompactionFailed
|
||||
|
||||
/**
|
||||
* Marks the Session going idle: every step since the previous marker belongs to
|
||||
* one turn, including prompts steered in while it was busy. A shutdown does not
|
||||
* record one, since the resumed execution continues the same turn.
|
||||
*/
|
||||
export interface Idle extends Schema.Schema.Type<typeof Idle> {}
|
||||
export const Idle = Schema.Struct({
|
||||
...Base,
|
||||
type: Schema.tag("idle"),
|
||||
outcome: Schema.Literals(["succeeded", "failed", "interrupted"]),
|
||||
}).annotate({ identifier: "Session.Message.Idle" })
|
||||
|
||||
export const Info = Schema.Union([
|
||||
AgentSelected,
|
||||
ModelSelected,
|
||||
@@ -303,7 +291,6 @@ export const Info = Schema.Union([
|
||||
Shell,
|
||||
Assistant,
|
||||
Compaction,
|
||||
Idle,
|
||||
]).annotate({ identifier: "Session.Message.Info" })
|
||||
export type Info =
|
||||
| AgentSelected
|
||||
@@ -316,5 +303,4 @@ export type Info =
|
||||
| Shell
|
||||
| Assistant
|
||||
| Compaction
|
||||
| Idle
|
||||
export type Type = Info["type"]
|
||||
|
||||
@@ -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,6 +1,5 @@
|
||||
import { Session } from "@opencode/core/session"
|
||||
import type { Snapshot } from "@opencode/core/snapshot"
|
||||
import { MessageNotFoundError, SessionNotFoundError, UnknownError } from "@opencode/protocol/errors"
|
||||
import { SessionNotFoundError, UnknownError } from "@opencode/protocol/errors"
|
||||
import { Effect } from "effect"
|
||||
|
||||
export function missingSession(error: Session.NotFoundError) {
|
||||
@@ -10,14 +9,6 @@ export function missingSession(error: Session.NotFoundError) {
|
||||
})
|
||||
}
|
||||
|
||||
export function missingMessage(error: Session.MessageNotFoundError) {
|
||||
return new MessageNotFoundError({
|
||||
sessionID: error.sessionID,
|
||||
messageID: error.messageID,
|
||||
message: `Message not found: ${error.messageID}`,
|
||||
})
|
||||
}
|
||||
|
||||
export function failedMessageDecode(error: Session.MessageDecodeError) {
|
||||
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
|
||||
return Effect.logError("failed to decode session message").pipe(
|
||||
@@ -27,16 +18,3 @@ export function failedMessageDecode(error: Session.MessageDecodeError) {
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Snapshot repositories are host state clients cannot repair, so surface only a log reference. */
|
||||
export function failedSnapshot(operation: string, sessionID: Session.ID) {
|
||||
return (error: Snapshot.Error) => {
|
||||
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
|
||||
return Effect.logError(`failed to ${operation}`, { cause: error }).pipe(
|
||||
Effect.annotateLogs({ ref, sessionID }),
|
||||
Effect.andThen(
|
||||
Effect.fail(new UnknownError({ message: "Unexpected server error. Check server logs for details.", ref })),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,9 +17,10 @@ import {
|
||||
ServiceUnavailableError,
|
||||
SessionBusyError,
|
||||
SkillNotFoundError,
|
||||
UnknownError,
|
||||
} from "@opencode/protocol/errors"
|
||||
import { AbsolutePath } from "@opencode/core/schema"
|
||||
import { failedMessageDecode, failedSnapshot, missingMessage, missingSession } from "./session-error"
|
||||
import { failedMessageDecode, missingSession } from "./session-error"
|
||||
|
||||
const DefaultSessionsLimit = 50
|
||||
|
||||
@@ -211,7 +212,15 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
return {
|
||||
data: yield* session.fork({ sessionID: ctx.params.sessionID, boundary: ctx.payload.boundary }).pipe(
|
||||
Effect.catchTag("Session.NotFoundError", missingSession),
|
||||
Effect.catchTag("Session.MessageNotFoundError", missingMessage),
|
||||
Effect.catchTag(
|
||||
"Session.MessageNotFoundError",
|
||||
(error) =>
|
||||
new MessageNotFoundError({
|
||||
sessionID: error.sessionID,
|
||||
messageID: error.messageID,
|
||||
message: `Message not found: ${error.messageID}`,
|
||||
}),
|
||||
),
|
||||
Effect.catchTag(
|
||||
"Session.ForkEmptyError",
|
||||
(error) => new InvalidRequestError({ message: error.message, kind: "empty_session" }),
|
||||
@@ -439,14 +448,32 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
files: ctx.payload.files,
|
||||
})
|
||||
return {
|
||||
data: yield* session.revert
|
||||
.stage({ ...ctx.params, ...ctx.payload })
|
||||
.pipe(
|
||||
Effect.catchTag("Session.NotFoundError", missingSession),
|
||||
Effect.catchTag("Session.MessageNotFoundError", missingMessage),
|
||||
Effect.catchTag("Session.BusyError", busySession),
|
||||
Effect.catchTag("Snapshot.Error", failedSnapshot("stage session revert", ctx.params.sessionID)),
|
||||
data: yield* session.revert.stage({ ...ctx.params, ...ctx.payload }).pipe(
|
||||
Effect.catchTag("Session.NotFoundError", missingSession),
|
||||
Effect.catchTag(
|
||||
"Session.MessageNotFoundError",
|
||||
(error) =>
|
||||
new MessageNotFoundError({
|
||||
sessionID: error.sessionID,
|
||||
messageID: error.messageID,
|
||||
message: `Message not found: ${error.messageID}`,
|
||||
}),
|
||||
),
|
||||
Effect.catchTag("Session.BusyError", busySession),
|
||||
Effect.catchTag("Snapshot.Error", (error) => {
|
||||
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
|
||||
return Effect.logError("failed to stage session revert", { cause: error }).pipe(
|
||||
Effect.andThen(
|
||||
Effect.fail(
|
||||
new UnknownError({
|
||||
message: "Unexpected server error. Check server logs for details.",
|
||||
ref,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -454,13 +481,23 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
"session.revert.clear",
|
||||
Effect.fn(function* (ctx) {
|
||||
yield* Effect.log("session.revert.clear", { sessionID: ctx.params.sessionID })
|
||||
yield* session.revert
|
||||
.clear(ctx.params.sessionID)
|
||||
.pipe(
|
||||
Effect.catchTag("Session.NotFoundError", missingSession),
|
||||
Effect.catchTag("Session.BusyError", busySession),
|
||||
Effect.catchTag("Snapshot.Error", failedSnapshot("clear session revert", ctx.params.sessionID)),
|
||||
)
|
||||
yield* session.revert.clear(ctx.params.sessionID).pipe(
|
||||
Effect.catchTag("Session.NotFoundError", missingSession),
|
||||
Effect.catchTag("Session.BusyError", busySession),
|
||||
Effect.catchTag("Snapshot.Error", (error) => {
|
||||
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
|
||||
return Effect.logError("failed to clear session revert", { cause: error }).pipe(
|
||||
Effect.andThen(
|
||||
Effect.fail(
|
||||
new UnknownError({
|
||||
message: "Unexpected server error. Check server logs for details.",
|
||||
ref,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
@@ -490,22 +527,6 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
}
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.diff",
|
||||
Effect.fn(function* (ctx) {
|
||||
return {
|
||||
data: yield* session.diff({ sessionID: ctx.params.sessionID, ...ctx.query }).pipe(
|
||||
Effect.catchTag("Session.NotFoundError", missingSession),
|
||||
Effect.catchTag("Session.MessageNotFoundError", missingMessage),
|
||||
Effect.catchTag(
|
||||
"Session.TurnRangeError",
|
||||
(error) => new InvalidRequestError({ message: error.message, field: error.field }),
|
||||
),
|
||||
Effect.catchTag("Snapshot.Error", failedSnapshot("diff session turn", ctx.params.sessionID)),
|
||||
),
|
||||
}
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.inbox.list",
|
||||
Effect.fn(function* (ctx) {
|
||||
|
||||
@@ -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" })
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -1,98 +0,0 @@
|
||||
import { expect, setDefaultTimeout } from "bun:test"
|
||||
import { Agent } from "@opencode/core/agent"
|
||||
import { Bus } from "@opencode/core/bus"
|
||||
import { Model } from "@opencode/core/model"
|
||||
import { Provider } from "@opencode/core/provider"
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { SessionEvent } from "@opencode/core/session/event"
|
||||
import { SessionExecution } from "@opencode/core/session/execution"
|
||||
import { SessionMessage } from "@opencode/core/session/message"
|
||||
import { Money } from "@opencode/schema/money"
|
||||
import { makeGlobalNode } from "@opencode/util/effect/app-node"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { tmpdir } from "../../core/test/fixture/tmpdir"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { ServerFetch } from "../src/fetch"
|
||||
|
||||
setDefaultTimeout(30_000)
|
||||
|
||||
it.live("serves turn diffs by user message with range validation", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-session-diff-")))
|
||||
const ids = { user: SessionMessage.ID.create(), assistant: SessionMessage.ID.create() }
|
||||
// Deliver the prompt and one step the way the runner would, without a model.
|
||||
const execution = Layer.effect(
|
||||
SessionExecution.Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
return SessionExecution.Service.of({
|
||||
active: Effect.succeed(new Set()),
|
||||
isActive: () => Effect.succeed(false),
|
||||
resume: () => Effect.void,
|
||||
wake: (sessionID) =>
|
||||
Effect.gen(function* () {
|
||||
yield* bus.publish(SessionEvent.InboxDelivered, { sessionID, inboxID: ids.user })
|
||||
yield* bus.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
assistantMessageID: ids.assistant,
|
||||
agent: Agent.defaultID,
|
||||
model: { id: Model.ID.make("model"), providerID: Provider.ID.make("provider") },
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Step.Ended, {
|
||||
sessionID,
|
||||
assistantMessageID: ids.assistant,
|
||||
finish: "stop",
|
||||
cost: Money.USD.zero,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
}),
|
||||
interrupt: () => Effect.succeed(false),
|
||||
awaitIdle: () => Effect.void,
|
||||
})
|
||||
}),
|
||||
)
|
||||
const handler = yield* ServerFetch.make(
|
||||
{
|
||||
app: { version: "test-version" },
|
||||
database: { path: ":memory:" },
|
||||
fs: { filewatcher: false },
|
||||
models: { fetch: false },
|
||||
},
|
||||
{
|
||||
overrides: [
|
||||
SessionExecution.node.replace(
|
||||
makeGlobalNode({ service: SessionExecution.Service, layer: execution, deps: [Bus.node] }),
|
||||
),
|
||||
],
|
||||
},
|
||||
)
|
||||
const request = (path: string, body?: unknown) =>
|
||||
Effect.promise(async () => {
|
||||
const response = await handler(
|
||||
new Request(`http://opencode.local${path}`, {
|
||||
method: body === undefined ? "GET" : "POST",
|
||||
headers: body === undefined ? undefined : { "content-type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
}),
|
||||
)
|
||||
return { status: response.status, body: (await response.json()) as Record<string, unknown> }
|
||||
})
|
||||
const created = yield* request("/api/session", { location: { directory: tmp.path } })
|
||||
const sessionID = Session.ID.make((created.body.data as { id: string }).id)
|
||||
const diff = (query = "") => request(`/api/session/${sessionID}/diff${query}`)
|
||||
|
||||
expect(yield* diff()).toEqual({ status: 200, body: { data: [] } })
|
||||
expect((yield* request(`/api/session/${sessionID}/prompt`, { id: ids.user, text: "prompt" })).status).toBe(200)
|
||||
// Not a git repository, so steps record no snapshots and the turn has no diff.
|
||||
expect(yield* diff(`?messageID=${ids.user}&context=3`)).toEqual({ status: 200, body: { data: [] } })
|
||||
expect(yield* diff(`?messageID=${ids.assistant}`)).toMatchObject({
|
||||
status: 400,
|
||||
body: { _tag: "InvalidRequestError", field: "messageID" },
|
||||
})
|
||||
expect(yield* diff(`?messageID=${SessionMessage.ID.create()}`)).toMatchObject({
|
||||
status: 404,
|
||||
body: { _tag: "MessageNotFoundError" },
|
||||
})
|
||||
expect((yield* request(`/api/session/${Session.ID.create()}/diff`)).status).toBe(404)
|
||||
}),
|
||||
)
|
||||
@@ -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")
|
||||
|
||||
@@ -427,17 +427,6 @@ export function SessionCompactionMessage(props: { message: SessionMessageCompact
|
||||
<div class="py-2">
|
||||
<TimelineSeparator label={i18n.t("ui.messagePart.compaction.started")} />
|
||||
</div>
|
||||
<Show when={summary().trim()}>
|
||||
<div data-component="text-part" data-timeline-part-id={props.message.id}>
|
||||
<div data-slot="text-part-body">
|
||||
<PacedMarkdown
|
||||
text={summary()}
|
||||
cacheKey={props.message.id}
|
||||
streaming={props.message.status === "running"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={props.message.status === "running"}>
|
||||
<div role="status" class="py-2">
|
||||
<BasicTool
|
||||
@@ -449,6 +438,17 @@ export function SessionCompactionMessage(props: { message: SessionMessageCompact
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={summary().trim()}>
|
||||
<div data-component="text-part" data-timeline-part-id={props.message.id}>
|
||||
<div data-slot="text-part-body">
|
||||
<PacedMarkdown
|
||||
text={summary()}
|
||||
cacheKey={props.message.id}
|
||||
streaming={props.message.status === "running"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={props.message.status !== "running"}>
|
||||
<div class="py-2">
|
||||
<TimelineSeparator label={label()} />
|
||||
|
||||
@@ -16,7 +16,7 @@ export { TimelineRow, type PartGroup, type PartRef, type TimelineRowMap }
|
||||
|
||||
export type ReasoningMode = "hidden" | "compact" | "full"
|
||||
|
||||
type Notice = Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" | "idle" }>
|
||||
type Notice = Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" }>
|
||||
type Entry = { type: "assistant"; message: SessionMessageAssistant } | { type: "notice"; message: Notice }
|
||||
type Content = SessionMessageAssistant["content"][number]
|
||||
type GroupRow = Extract<TimelineRow.TimelineRow, { _tag: "AssistantPart" }>
|
||||
@@ -765,8 +765,7 @@ function record(value: unknown): value is Record<string, unknown> {
|
||||
}
|
||||
|
||||
function isNotice(message: SessionMessageInfo): message is Notice {
|
||||
if (message.type === "user" || message.type === "assistant" || message.type === "shell" || message.type === "idle")
|
||||
return false
|
||||
if (message.type === "user" || message.type === "assistant" || message.type === "shell") return false
|
||||
if (message.type !== "synthetic") return true
|
||||
return !!message.description?.trim() || timelineNoticeRequired(message)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,7 +305,6 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S
|
||||
...messages.filter(isInput),
|
||||
].reduce<SessionRow[]>((rows, message) => {
|
||||
if (message.type !== "assistant") {
|
||||
if (message.type === "idle") return rows
|
||||
if (message.type === "synthetic" && !message.description?.trim()) return rows
|
||||
if (message.type === "compaction" && message.status === "completed" && usage) usage.previousTurnCache = undefined
|
||||
if (!pending.has(message.id)) completePrevious(rows)
|
||||
|
||||
+1
-234
@@ -3242,152 +3242,6 @@
|
||||
"summary": "Get session context"
|
||||
}
|
||||
},
|
||||
"/api/session/{sessionID}/diff": {
|
||||
"get": {
|
||||
"tags": ["session"],
|
||||
"operationId": "v2.session.diff",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"pattern": "^ses"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "messageID",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^msg_"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "User message whose turn to diff. Defaults to the turn of the newest user message."
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "to",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^msg_"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Later user message whose turn ends the range. Defaults to the turn of `messageID` alone."
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "context",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Unchanged lines around each hunk. Omit for full-file patches."
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/FileDiff.Info"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "MessageNotFoundError | SessionNotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/MessageNotFoundErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "UnknownError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnknownErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Structured per-file diffs of the files a turn changed. A turn runs from the first prompt after the session was last idle until its next idle marker, so prompts steered in while it was busy belong to the same turn; `to` extends the range through a later turn. Compares the range's first recorded snapshot with its last; a step still running in the active session compares against the working copy. Ranges that span a location change are rejected. In sessions without any idle marker, a prompt's turn spans until the next user message.",
|
||||
"summary": "Diff session turns"
|
||||
}
|
||||
},
|
||||
"/api/session/{sessionID}/inbox": {
|
||||
"get": {
|
||||
"tags": ["session"],
|
||||
@@ -4636,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": [],
|
||||
@@ -4726,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"
|
||||
}
|
||||
},
|
||||
@@ -14417,9 +14243,6 @@
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"websocket": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"modelID": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -14528,9 +14351,6 @@
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"websocket": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"canonical": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -16477,9 +16297,6 @@
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"websocket": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"settings": {
|
||||
"type": "object"
|
||||
},
|
||||
@@ -17468,9 +17285,6 @@
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"websocket": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"settings": {
|
||||
"type": "object"
|
||||
},
|
||||
@@ -18442,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"],
|
||||
@@ -18487,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"],
|
||||
@@ -18540,38 +18342,6 @@
|
||||
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Session.Message.Idle": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^msg_"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
"time": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": ["created"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["idle"]
|
||||
},
|
||||
"outcome": {
|
||||
"type": "string",
|
||||
"enum": ["succeeded", "failed", "interrupted"]
|
||||
}
|
||||
},
|
||||
"required": ["id", "time", "type", "outcome"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Session.Message.Info": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -18603,9 +18373,6 @@
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Session.Message.Compaction"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Session.Message.Idle"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -3242,152 +3242,6 @@
|
||||
"summary": "Get session context"
|
||||
}
|
||||
},
|
||||
"/api/session/{sessionID}/diff": {
|
||||
"get": {
|
||||
"tags": ["session"],
|
||||
"operationId": "v2.session.diff",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"pattern": "^ses"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "messageID",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^msg_"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "User message whose turn to diff. Defaults to the turn of the newest user message."
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "to",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^msg_"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Later user message whose turn ends the range. Defaults to the turn of `messageID` alone."
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "context",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Unchanged lines around each hunk. Omit for full-file patches."
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/FileDiff.Info"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "MessageNotFoundError | SessionNotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/MessageNotFoundErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "UnknownError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnknownErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Structured per-file diffs of the files a turn changed. A turn runs from the first prompt after the session was last idle until its next idle marker, so prompts steered in while it was busy belong to the same turn; `to` extends the range through a later turn. Compares the range's first recorded snapshot with its last; a step still running in the active session compares against the working copy. Ranges that span a location change are rejected. In sessions without any idle marker, a prompt's turn spans until the next user message.",
|
||||
"summary": "Diff session turns"
|
||||
}
|
||||
},
|
||||
"/api/session/{sessionID}/inbox": {
|
||||
"get": {
|
||||
"tags": ["session"],
|
||||
@@ -4636,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": [],
|
||||
@@ -4726,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"
|
||||
}
|
||||
},
|
||||
@@ -14417,9 +14243,6 @@
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"websocket": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"modelID": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -14528,9 +14351,6 @@
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"websocket": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"canonical": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -16477,9 +16297,6 @@
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"websocket": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"settings": {
|
||||
"type": "object"
|
||||
},
|
||||
@@ -17468,9 +17285,6 @@
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"websocket": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"settings": {
|
||||
"type": "object"
|
||||
},
|
||||
@@ -18442,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"],
|
||||
@@ -18487,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"],
|
||||
@@ -18540,38 +18342,6 @@
|
||||
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Session.Message.Idle": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^msg_"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
"time": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": ["created"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["idle"]
|
||||
},
|
||||
"outcome": {
|
||||
"type": "string",
|
||||
"enum": ["succeeded", "failed", "interrupted"]
|
||||
}
|
||||
},
|
||||
"required": ["id", "time", "type", "outcome"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Session.Message.Info": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -18603,9 +18373,6 @@
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Session.Message.Compaction"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Session.Message.Idle"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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 }
|
||||
}),
|
||||
)
|
||||
}),
|
||||
```
|
||||
|
||||
@@ -1185,9 +1178,6 @@ 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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1333,9 +1321,6 @@ 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
|
||||
@@ -1353,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
|
||||
@@ -1370,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