mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-10 10:56:22 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2af928895 |
@@ -181,7 +181,7 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
|
||||
responseID,
|
||||
request,
|
||||
// Completion can re-encrypt reasoning. Callers replay the item already emitted by output_item.done.
|
||||
output: event.response?.output?.length
|
||||
output: event.response?.output
|
||||
? event.response.output.map((item) =>
|
||||
item.type === "reasoning" && item.id !== undefined
|
||||
? (output.find((done) => done.type === item.type && done.id === item.id) ?? item)
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { OpenResponses } from "../protocols/open-responses.js"
|
||||
import { OpenAIResponses } from "../protocols/openai-responses.js"
|
||||
import { BedrockAuth, type Credentials } from "../protocols/utils/bedrock-auth.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
@@ -38,10 +37,11 @@ const responsesRoute = Route.make({
|
||||
id: "bedrock-mantle-responses",
|
||||
provider: id,
|
||||
providerMetadataKey: "mantle",
|
||||
protocol: OpenResponses.protocol,
|
||||
endpoint: Endpoint.path(OpenResponses.PATH),
|
||||
transport: OpenResponses.httpTransport,
|
||||
defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } },
|
||||
protocol: OpenAIResponses.protocol,
|
||||
endpoint: OpenAIResponses.route.endpoint,
|
||||
auth: OpenAIResponses.route.auth,
|
||||
transport: OpenAIResponses.httpTransport,
|
||||
defaults: OpenAIResponses.route.defaults,
|
||||
})
|
||||
|
||||
const chatRoute = OpenAIChat.route.with({
|
||||
|
||||
@@ -4,7 +4,7 @@ import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, Message } from "../../src/index.js"
|
||||
import { AmazonBedrockMantle } from "../../src/providers.js"
|
||||
import { model } from "../../src/providers/amazon-bedrock/mantle.js"
|
||||
import { OpenResponses } from "../../src/protocols/open-responses.js"
|
||||
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
|
||||
import { compileRequest, LLMClient } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { withProcessEnv } from "../lib/env.js"
|
||||
@@ -25,7 +25,7 @@ describe("Amazon Bedrock Mantle provider", () => {
|
||||
expect(provider.model).toBe(provider.responses)
|
||||
expect(AmazonBedrockMantle.model).toBe(AmazonBedrockMantle.responsesModel)
|
||||
expect(model).toBe(AmazonBedrockMantle.responsesModel)
|
||||
expect(provider.model("openai.gpt-oss-120b").route.transport).toBe(OpenResponses.httpTransport)
|
||||
expect(provider.model("openai.gpt-oss-120b").route.transport).toBe(OpenAIResponses.httpTransport)
|
||||
const chat = yield* compileRequest(LLM.request({ model: provider.chat("openai.gpt-oss-120b"), prompt: "Hi" }))
|
||||
const responses = yield* compileRequest(
|
||||
LLM.request({ model: provider.model("openai.gpt-oss-120b"), prompt: "Hi" }),
|
||||
@@ -38,7 +38,7 @@ describe("Amazon Bedrock Mantle provider", () => {
|
||||
})
|
||||
expect(responses).toMatchObject({
|
||||
route: "bedrock-mantle-responses",
|
||||
protocol: "open-responses",
|
||||
protocol: "openai-responses",
|
||||
body: { model: "openai.gpt-oss-120b", store: false },
|
||||
})
|
||||
expect(provider.model("openai.gpt-oss-120b").route.providerMetadataKey).toBe("mantle")
|
||||
@@ -178,7 +178,7 @@ describe("Amazon Bedrock Mantle provider", () => {
|
||||
const recorded = recordedTests({
|
||||
prefix: "bedrock-mantle",
|
||||
provider: "amazon-bedrock",
|
||||
protocol: "open-responses",
|
||||
protocol: "openai-responses",
|
||||
requires: ["AWS_BEARER_TOKEN_BEDROCK"],
|
||||
metadata: { model: "openai.gpt-oss-120b" },
|
||||
})
|
||||
|
||||
@@ -581,54 +581,52 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
it.effect("continues a streamed tool call with only the new tool output", () =>
|
||||
Effect.forEach([undefined, []], (output) =>
|
||||
Effect.gen(function* () {
|
||||
const firstRequest = {
|
||||
type: "response.create",
|
||||
model: "gpt-5.2",
|
||||
store: false,
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "Weather?" }] }],
|
||||
}
|
||||
const first = continuationDriver(firstRequest)
|
||||
const firstCreate = yield* first.create(undefined)
|
||||
Effect.gen(function* () {
|
||||
const firstRequest = {
|
||||
type: "response.create",
|
||||
model: "gpt-5.2",
|
||||
store: false,
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "Weather?" }] }],
|
||||
}
|
||||
const first = continuationDriver(firstRequest)
|
||||
const firstCreate = yield* first.create(undefined)
|
||||
yield* first.observe(
|
||||
firstCreate,
|
||||
ProviderShared.encodeJson({
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: "fc_1",
|
||||
status: "completed",
|
||||
call_id: "call_1",
|
||||
name: "weather",
|
||||
arguments: '{ "city": "Paris" }',
|
||||
},
|
||||
}),
|
||||
)
|
||||
const saved = checkpoint(
|
||||
yield* first.observe(
|
||||
firstCreate,
|
||||
ProviderShared.encodeJson({
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: "fc_1",
|
||||
status: "completed",
|
||||
call_id: "call_1",
|
||||
name: "weather",
|
||||
arguments: '{ "city": "Paris" }',
|
||||
},
|
||||
}),
|
||||
)
|
||||
const saved = checkpoint(
|
||||
yield* first.observe(
|
||||
firstCreate,
|
||||
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1", output } }),
|
||||
),
|
||||
)
|
||||
const second = continuationDriver({
|
||||
...firstRequest,
|
||||
input: [
|
||||
...firstRequest.input,
|
||||
{ type: "function_call", call_id: "call_1", name: "weather", arguments: '{"city":"Paris"}' },
|
||||
{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' },
|
||||
],
|
||||
})
|
||||
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
|
||||
),
|
||||
)
|
||||
const second = continuationDriver({
|
||||
...firstRequest,
|
||||
input: [
|
||||
...firstRequest.input,
|
||||
{ type: "function_call", call_id: "call_1", name: "weather", arguments: '{"city":"Paris"}' },
|
||||
{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' },
|
||||
],
|
||||
})
|
||||
|
||||
const create = yield* second.create(saved)
|
||||
const create = yield* second.create(saved)
|
||||
|
||||
expect(create.mode).toBe("incremental")
|
||||
expect(ProviderShared.decodeJson(create.message)).toMatchObject({
|
||||
previous_response_id: "resp_1",
|
||||
input: [{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' }],
|
||||
})
|
||||
}),
|
||||
),
|
||||
expect(create.mode).toBe("incremental")
|
||||
expect(ProviderShared.decodeJson(create.message)).toMatchObject({
|
||||
previous_response_id: "resp_1",
|
||||
input: [{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues a tool call from authoritative completed response output", () =>
|
||||
@@ -682,47 +680,45 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
it.effect("continues a promoted steer after assistant output with response-only text metadata", () =>
|
||||
Effect.forEach([undefined, []], (output) =>
|
||||
Effect.gen(function* () {
|
||||
const firstInput = [{ role: "user", content: [{ type: "input_text", text: "First" }] }]
|
||||
const first = continuationDriver({ type: "response.create", model: "gpt-5.2", store: false, input: firstInput })
|
||||
const create = yield* first.create(undefined)
|
||||
Effect.gen(function* () {
|
||||
const firstInput = [{ role: "user", content: [{ type: "input_text", text: "First" }] }]
|
||||
const first = continuationDriver({ type: "response.create", model: "gpt-5.2", store: false, input: firstInput })
|
||||
const create = yield* first.create(undefined)
|
||||
yield* first.observe(
|
||||
create,
|
||||
ProviderShared.encodeJson({
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "message",
|
||||
id: "msg_1",
|
||||
status: "completed",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Hello", annotations: [], logprobs: [] }],
|
||||
},
|
||||
}),
|
||||
)
|
||||
const saved = checkpoint(
|
||||
yield* first.observe(
|
||||
create,
|
||||
ProviderShared.encodeJson({
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "message",
|
||||
id: "msg_1",
|
||||
status: "completed",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Hello", annotations: [], logprobs: [] }],
|
||||
},
|
||||
}),
|
||||
)
|
||||
const saved = checkpoint(
|
||||
yield* first.observe(
|
||||
create,
|
||||
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1", output } }),
|
||||
),
|
||||
)
|
||||
const steer = { role: "user", content: [{ type: "input_text", text: "Actually, be brief" }] }
|
||||
const next = continuationDriver({
|
||||
type: "response.create",
|
||||
model: "gpt-5.2",
|
||||
store: false,
|
||||
input: [...firstInput, { role: "assistant", content: [{ type: "output_text", text: "Hello" }] }, steer],
|
||||
})
|
||||
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
|
||||
),
|
||||
)
|
||||
const steer = { role: "user", content: [{ type: "input_text", text: "Actually, be brief" }] }
|
||||
const next = continuationDriver({
|
||||
type: "response.create",
|
||||
model: "gpt-5.2",
|
||||
store: false,
|
||||
input: [...firstInput, { role: "assistant", content: [{ type: "output_text", text: "Hello" }] }, steer],
|
||||
})
|
||||
|
||||
const continued = yield* next.create(saved)
|
||||
const continued = yield* next.create(saved)
|
||||
|
||||
expect(continued.mode).toBe("incremental")
|
||||
expect(ProviderShared.decodeJson(continued.message)).toMatchObject({
|
||||
previous_response_id: "resp_1",
|
||||
input: [steer],
|
||||
})
|
||||
}),
|
||||
),
|
||||
expect(continued.mode).toBe("incremental")
|
||||
expect(ProviderShared.decodeJson(continued.message)).toMatchObject({
|
||||
previous_response_id: "resp_1",
|
||||
input: [steer],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues streamed reasoning when completion re-encrypts the same item", () =>
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode/client/promise"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { installStressSessionTabs, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 }, serviceWorkers: "block" })
|
||||
|
||||
test("keeps five loaded workspace tabs visible and reactive through repeated switches", async ({ page }, info) => {
|
||||
const sessions = Array.from({ length: 5 }, (_, index) => ({
|
||||
...fixture.sessions[0]!,
|
||||
id: `ses_workspace_cycle_${index}`,
|
||||
directory: `${fixture.directory}/worktree-${index}`,
|
||||
title: `Workspace session ${index}`,
|
||||
}))
|
||||
const events: OpenCodeEvent[] = []
|
||||
await mockOpenCodeServer(page, {
|
||||
...fixture,
|
||||
sessions,
|
||||
pageMessages: (id) => ({
|
||||
items: [
|
||||
{ id: `msg_user_${id}`, type: "user", text: `Prompt for ${id}`, time: { created: 1 } },
|
||||
{
|
||||
id: `msg_assistant_${id}`,
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "claude-opus-4-6", providerID: "opencode" },
|
||||
time: { created: 2, completed: 3 },
|
||||
content: [{ type: "text", text: `Answer for ${id}` }],
|
||||
},
|
||||
] satisfies SessionMessageInfo[],
|
||||
}),
|
||||
events: () => events.splice(0),
|
||||
})
|
||||
await page.route("**/api/location?*", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
directory: new URL(route.request().url()).searchParams.get("location[directory]"),
|
||||
project: { id: fixture.project.id, directory: fixture.directory, canonical: fixture.directory },
|
||||
},
|
||||
}),
|
||||
)
|
||||
await installStressSessionTabs(page, { sessionIDs: sessions.map((session) => session.id) })
|
||||
await page.goto(stressSessionHref(sessions[0]!.id))
|
||||
await expect(page.getByText(`Answer for ${sessions[0]!.id}`, { exact: true })).toBeVisible()
|
||||
|
||||
for (const session of [...sessions.slice(1), ...sessions, ...sessions.toReversed()]) {
|
||||
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(session.id)}"]`).click()
|
||||
await expect(page.locator(`[data-timeline-part-id="msg_assistant_${session.id}:text:0"]`)).toBeVisible()
|
||||
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
|
||||
}
|
||||
const active = sessions[0]!
|
||||
events.push({
|
||||
id: "evt_workspace_cycle_update",
|
||||
created: 4,
|
||||
type: "session.text.ended",
|
||||
location: { directory: active.directory },
|
||||
durable: { aggregateID: active.id, seq: 0, version: 1 },
|
||||
data: {
|
||||
sessionID: active.id,
|
||||
assistantMessageID: `msg_assistant_${active.id}`,
|
||||
ordinal: 0,
|
||||
text: "Still receiving updates",
|
||||
},
|
||||
})
|
||||
await expect(page.getByText("Still receiving updates", { exact: true })).toBeVisible()
|
||||
await page.screenshot({ path: info.outputPath("workspace-tabs.png") })
|
||||
})
|
||||
@@ -18,7 +18,6 @@ export function createTimelineCache(
|
||||
visible: Accessor<boolean>,
|
||||
) {
|
||||
const owner = getOwner()
|
||||
let workspace = untrack(session.identity.workspaceKey)
|
||||
const cache = createScopedCache(
|
||||
(key) =>
|
||||
createRoot((dispose) => {
|
||||
@@ -52,18 +51,8 @@ export function createTimelineCache(
|
||||
{ maxEntries: 16, dispose: (entry) => entry.dispose() },
|
||||
)
|
||||
onCleanup(cache.clear)
|
||||
const syncWorkspace = (key: string) => {
|
||||
if (workspace === key) return
|
||||
workspace = key
|
||||
cache.clear()
|
||||
}
|
||||
// Providers follow the selected Location even while its history is loading.
|
||||
// Dispose detached views before their effects can read the new Location.
|
||||
createComputed(on(session.identity.workspaceKey, syncWorkspace, { defer: true }))
|
||||
return () => {
|
||||
// A tab's render can run before the workspace watcher in the same batch.
|
||||
// Clear the old workspace here, and let that later watcher keep this view.
|
||||
syncWorkspace(session.identity.workspaceKey())
|
||||
return cache.get(session.identity.sessionKey()).value
|
||||
}
|
||||
createComputed(on(session.identity.workspaceKey, cache.clear, { defer: true }))
|
||||
return () => cache.get(session.identity.sessionKey()).value
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { SessionMessageInfo } from "@opencode/client/promise"
|
||||
import { batch, createMemo, createRoot, onCleanup } from "solid-js"
|
||||
import { createMemo, createRoot, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { ServerScope, SessionRouteKey, SessionStateKey } from "../src/runtime/server/scope"
|
||||
import { createTimelineCache } from "../src/session/timeline/cache"
|
||||
@@ -144,35 +144,6 @@ test("disposes views on workspace changes while the destination is not rendered"
|
||||
expect(input.disposed).toEqual(["ses_a", "ses_a"])
|
||||
})
|
||||
|
||||
for (const order of ["session-first", "workspace-first"] as const) {
|
||||
test(`keeps views live across five workspaces when updates are ${order}`, () => {
|
||||
const input = setup()
|
||||
const render = createRoot((dispose) => ({ selected: createMemo(input.cache), dispose }))
|
||||
const visited = ["ses_a"]
|
||||
try {
|
||||
;["ses_b", "ses_c", "ses_d", "ses_e", "ses_a", "ses_c", "ses_b", "ses_e", "ses_d", "ses_a"].forEach(
|
||||
(id, index) => {
|
||||
batch(() => {
|
||||
if (order === "workspace-first") input.setState("directory", `/repo/${id}`)
|
||||
input.setState("id", id)
|
||||
if (order === "session-first") input.setState("directory", `/repo/${id}`)
|
||||
})
|
||||
expect(input.disposed).toEqual(visited)
|
||||
input.setState("messages", id, [
|
||||
{ id: `msg_live_${index}`, type: "user", text: "Live update", time: { created: index + 3 } },
|
||||
])
|
||||
expect((render.selected() as HTMLDivElement).dataset.messages).toBe(`msg_live_${index}`)
|
||||
expect(input.views.get(id)!.active()).toBe(true)
|
||||
visited.push(id)
|
||||
},
|
||||
)
|
||||
} finally {
|
||||
render.dispose()
|
||||
input.dispose()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test("evicts the least recently selected view and disposes all retained owners", () => {
|
||||
const input = setup()
|
||||
try {
|
||||
|
||||
@@ -1166,18 +1166,6 @@ export type MessageListInput = {
|
||||
readonly limit?: number | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly cursor?: string | undefined
|
||||
readonly type?:
|
||||
| "agent-switched"
|
||||
| "model-switched"
|
||||
| "location-switched"
|
||||
| "user"
|
||||
| "synthetic"
|
||||
| "system"
|
||||
| "skill"
|
||||
| "shell"
|
||||
| "assistant"
|
||||
| "compaction"
|
||||
| undefined
|
||||
}
|
||||
export type MessageListOutput = {
|
||||
readonly data: ReadonlyArray<SessionMessage.Info>
|
||||
|
||||
@@ -757,7 +757,7 @@ const EndpointMessageList = (raw: RawClient["server.message"]) => (input: Messag
|
||||
preserveEffect<MessageListOutput>()(
|
||||
raw["session.messages"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
query: { limit: input["limit"], order: input["order"], cursor: input["cursor"], type: input["type"] },
|
||||
query: { limit: input["limit"], order: input["order"], cursor: input["cursor"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
|
||||
@@ -1015,7 +1015,7 @@ export function make(options: ClientOptions) {
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/message`,
|
||||
query: { limit: input["limit"], order: input["order"], cursor: input["cursor"], type: input["type"] },
|
||||
query: { limit: input["limit"], order: input["order"], cursor: input["cursor"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401, 404, 500],
|
||||
empty: false,
|
||||
|
||||
@@ -4348,70 +4348,17 @@ export type MessageListInput = {
|
||||
readonly limit?: number | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly cursor?: string | undefined
|
||||
readonly type?:
|
||||
| "agent-switched"
|
||||
| "model-switched"
|
||||
| "location-switched"
|
||||
| "user"
|
||||
| "synthetic"
|
||||
| "system"
|
||||
| "skill"
|
||||
| "shell"
|
||||
| "assistant"
|
||||
| "compaction"
|
||||
| undefined
|
||||
}["limit"]
|
||||
readonly order?: {
|
||||
readonly limit?: number | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly cursor?: string | undefined
|
||||
readonly type?:
|
||||
| "agent-switched"
|
||||
| "model-switched"
|
||||
| "location-switched"
|
||||
| "user"
|
||||
| "synthetic"
|
||||
| "system"
|
||||
| "skill"
|
||||
| "shell"
|
||||
| "assistant"
|
||||
| "compaction"
|
||||
| undefined
|
||||
}["order"]
|
||||
readonly cursor?: {
|
||||
readonly limit?: number | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly cursor?: string | undefined
|
||||
readonly type?:
|
||||
| "agent-switched"
|
||||
| "model-switched"
|
||||
| "location-switched"
|
||||
| "user"
|
||||
| "synthetic"
|
||||
| "system"
|
||||
| "skill"
|
||||
| "shell"
|
||||
| "assistant"
|
||||
| "compaction"
|
||||
| undefined
|
||||
}["cursor"]
|
||||
readonly type?: {
|
||||
readonly limit?: number | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly cursor?: string | undefined
|
||||
readonly type?:
|
||||
| "agent-switched"
|
||||
| "model-switched"
|
||||
| "location-switched"
|
||||
| "user"
|
||||
| "synthetic"
|
||||
| "system"
|
||||
| "skill"
|
||||
| "shell"
|
||||
| "assistant"
|
||||
| "compaction"
|
||||
| undefined
|
||||
}["type"]
|
||||
}
|
||||
|
||||
export type MessageListOutput = SessionMessagesResponse
|
||||
|
||||
@@ -31,8 +31,8 @@ ultimate source of truth.
|
||||
- [x] `null`, `undefined`, booleans, finite and non-finite numbers, and strings.
|
||||
- [x] Array literals, including holes and spread from arrays, strings, Maps, Sets, URLSearchParams, custom synchronous
|
||||
iterators, and synchronous generators.
|
||||
- [x] Object literals with shorthand, computed string/number keys, and spread following ToObject: data objects and
|
||||
arrays copy own enumerable keys, strings copy index keys, and other values contribute nothing.
|
||||
- [x] Object literals with shorthand, computed string/number keys, and spread from plain data objects; `null` and
|
||||
`undefined` are no-ops, while arrays are rejected.
|
||||
- [x] Template literals with interpolation.
|
||||
- [x] Regular-expression literals.
|
||||
- [x] `NaN` and `Infinity` globals.
|
||||
@@ -70,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.
|
||||
@@ -210,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
|
||||
|
||||
@@ -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,10 +85,18 @@ 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"
|
||||
|
||||
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 => {
|
||||
if (callee?.type === "Identifier") return callee.name
|
||||
if (callee?.type === "MemberExpression") {
|
||||
@@ -106,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(
|
||||
@@ -218,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(
|
||||
@@ -238,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> {
|
||||
@@ -845,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(
|
||||
@@ -865,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)
|
||||
@@ -1897,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
|
||||
}
|
||||
|
||||
@@ -1999,7 +2002,7 @@ class Frame<R> {
|
||||
|
||||
private getMemberReference(
|
||||
node: MemberExpression,
|
||||
operation: "read" | "write" | "delete" = "read",
|
||||
operation: "read" | "delete" = "read",
|
||||
): Effect.Effect<
|
||||
| MemberReference
|
||||
| ToolReference
|
||||
@@ -2040,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)
|
||||
@@ -2201,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 ||
|
||||
|
||||
@@ -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
|
||||
@@ -145,7 +121,10 @@ const rejectTools = (name: string, args: Array<unknown>, node: AstNode): void =>
|
||||
}
|
||||
|
||||
const objectStatic = (name: string, impl: (args: Array<unknown>, node: AstNode) => unknown) =>
|
||||
sync(`Object.${name}`, impl)
|
||||
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.
|
||||
@@ -160,19 +139,17 @@ 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: objectStatic("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(enumerableSource("Object.entries(...)", args[0], node)).map(([key, item]) => [key, item]),
|
||||
Object.entries(requireObject("entries", args[0], node)).map(([key, item]) => [key, item]),
|
||||
),
|
||||
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]),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
/**
|
||||
* Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75:
|
||||
* - test/built-ins/RegExp/S15.10.7_A3_T1.js
|
||||
* - test/built-ins/RegExp/S15.10.7_A3_T2.js
|
||||
* - test/built-ins/Object/S15.2.2.1_A1_T1.js
|
||||
*
|
||||
* Copyright 2009 the Sputnik authors. All rights reserved.
|
||||
* Test262 portions are governed by the BSD license in LICENSE.test262.
|
||||
*
|
||||
* Only the instance-side assertions are ported. Test262 otherwise reaches `constructor` through
|
||||
* `X.prototype.constructor`, boxed primitives (`new Object(1)`), `Function`, `isPrototypeOf`, or
|
||||
* `.call`, none of which CodeMode exposes: values have no prototype chain, so `x.constructor`
|
||||
* resolves directly to the owning built-in.
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { CodeMode } from "../src/index.js"
|
||||
|
||||
const value = async (code: string) => {
|
||||
const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} }))
|
||||
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
|
||||
return result.value
|
||||
}
|
||||
|
||||
describe("constructor Test262 parity", () => {
|
||||
test("test/built-ins/RegExp/S15.10.7_A3_T1.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const __re = /[^a]*/
|
||||
return [typeof __re, __re.constructor === RegExp, __re instanceof RegExp]
|
||||
`),
|
||||
).toEqual(["object", true, true])
|
||||
})
|
||||
|
||||
test("test/built-ins/RegExp/S15.10.7_A3_T2.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const __re = new RegExp()
|
||||
return [typeof __re, __re.constructor === RegExp, __re instanceof RegExp]
|
||||
`),
|
||||
).toEqual(["object", true, true])
|
||||
})
|
||||
|
||||
test("test/built-ins/Object/S15.2.2.1_A1_T1.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const obj = new Object()
|
||||
return [obj !== undefined, obj.constructor === Object]
|
||||
`),
|
||||
).toEqual([true, true])
|
||||
})
|
||||
|
||||
test("every built-in reports itself for its own values", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
return [
|
||||
[].constructor === Array, "".constructor === String, (1).constructor === Number, true.constructor === Boolean,
|
||||
new Date(0).constructor === Date, new Map().constructor === Map, new Set().constructor === Set,
|
||||
new URL("https://a.b/").constructor === URL, new URLSearchParams("a=1").constructor === URLSearchParams,
|
||||
Promise.resolve(1).constructor === Promise, new TypeError("x").constructor === TypeError,
|
||||
new RangeError("x").constructor === RangeError, new AggregateError([]).constructor === AggregateError,
|
||||
]
|
||||
`),
|
||||
).toEqual(Array(13).fill(true))
|
||||
})
|
||||
})
|
||||
@@ -85,16 +85,18 @@ describe("Object.keys over arrays", () => {
|
||||
expect(await value(`return Object.keys({ a: 1, b: 2 })`)).toEqual(["a", "b"])
|
||||
})
|
||||
|
||||
test("non-object inputs follow ToObject, and nullish inputs name what was received", async () => {
|
||||
expect(
|
||||
await value(`return [Object.keys("ab"), Object.entries(42), Object.keys(() => 1), Object.keys(true)]`),
|
||||
).toEqual([["0", "1"], [], [], []])
|
||||
expect(await value(`try { Object.values(null) } catch (e) { return [e.name, e.message] }`)).toEqual([
|
||||
"TypeError",
|
||||
"Object.values(...) cannot convert null to an object.",
|
||||
])
|
||||
test("non-object inputs name what was received", async () => {
|
||||
expect((await error(`return Object.keys("nope")`)).message).toContain(
|
||||
"Object.keys expects a data object or array, received a string.",
|
||||
)
|
||||
expect((await error(`return Object.entries(42)`)).message).toContain("received a number.")
|
||||
expect((await error(`return Object.values(null)`)).message).toContain("received null.")
|
||||
expect((await error(`return Object.keys(tools.github.list_issues({ value: "x" }))`)).message).toContain(
|
||||
"received an un-awaited Promise",
|
||||
"received an un-awaited Promise.",
|
||||
)
|
||||
expect((await error(`return Object.entries(() => 1)`)).message).toContain("received a function.")
|
||||
expect((await error(`return { ...[1] }`)).message).toContain(
|
||||
"Object spread requires a data object, received an array.",
|
||||
)
|
||||
expect((await error(`const { a } = new Map(); return a`)).message).toContain("received a Map.")
|
||||
expect((await error(`return Array.from(7)`)).message).toContain("received a number.")
|
||||
@@ -159,21 +161,11 @@ describe("for...in", () => {
|
||||
).toEqual(["github.list_issues", "github.get_issue", "memory.search", "playwright.navigate"])
|
||||
})
|
||||
|
||||
test("non-object values enumerate like JS: strings by index, everything else nothing", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const out = []
|
||||
for (const key in "ab") out.push(key)
|
||||
for (const key in 42) out.push(key)
|
||||
for (const key in null) out.push(key)
|
||||
for (const key in undefined) out.push(key)
|
||||
for (const key in new Map([[1, 2]])) out.push(key)
|
||||
for (const key in Math) out.push(key)
|
||||
return out
|
||||
`),
|
||||
).toEqual(["0", "1"])
|
||||
expect((await error(`for (const key in tools.github.list_issues({ value: "x" })) {}`)).message).toContain(
|
||||
"un-awaited Promise",
|
||||
)
|
||||
test("unsupported values fail with a hint at for...of and Object.keys", async () => {
|
||||
for (const expression of [`"text"`, "new Map([[1, 2]])", "new Set([1])", "42", "null"]) {
|
||||
const failure = await error(`for (const key in ${expression}) {}; return "no"`)
|
||||
expect(failure.message).toContain("for...in requires a plain object, array, or tools reference")
|
||||
expect(failure.message).toContain("Use for...of for arrays/strings/Maps/Sets, or Object.keys(value)")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
/**
|
||||
* Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75:
|
||||
* - test/built-ins/Object/keys/15.2.3.14-1-1.js
|
||||
* - test/built-ins/Object/keys/15.2.3.14-1-2.js
|
||||
* - test/built-ins/Object/keys/15.2.3.14-1-3.js
|
||||
* - test/built-ins/Object/keys/15.2.3.14-1-4.js
|
||||
* - test/built-ins/Object/keys/15.2.3.14-1-5.js
|
||||
* - test/built-ins/Object/entries/primitive-strings.js
|
||||
* - test/built-ins/Object/entries/primitive-numbers.js
|
||||
* - test/built-ins/Object/entries/primitive-booleans.js
|
||||
* - test/built-ins/Object/values/primitive-strings.js
|
||||
* - test/built-ins/Object/values/primitive-numbers.js
|
||||
* - test/built-ins/Object/values/primitive-booleans.js
|
||||
* - test/built-ins/Object/hasOwn/toobject_null.js
|
||||
* - test/built-ins/Object/hasOwn/toobject_undefined.js
|
||||
* - test/built-ins/Object/hasOwn/hasown_nonexistent.js
|
||||
* - test/built-ins/Object/assign/Source-String.js
|
||||
* - test/built-ins/Object/assign/Source-Null-Undefined.js
|
||||
* - test/built-ins/Object/assign/target-Array.js
|
||||
* - test/built-ins/Object/assign/Target-Null.js
|
||||
* - test/built-ins/Object/assign/Target-Undefined.js
|
||||
* - test/built-ins/Object/assign/Target-Object.js
|
||||
* - test/built-ins/Object/assign/Override.js
|
||||
* - test/built-ins/Object/assign/ObjectOverride-sameproperty.js
|
||||
*
|
||||
* Copyright (c) 2012 Ecma International. All rights reserved.
|
||||
* Copyright (C) 2015 Jordan Harband. All rights reserved.
|
||||
* Copyright 2015 Microsoft Corporation. All rights reserved.
|
||||
* Copyright 2021 Jamie Kyle. All rights reserved.
|
||||
* Test262 portions are governed by the BSD license in LICENSE.test262.
|
||||
*
|
||||
* Boxed-primitive cases (`Object.assign("a")`, `Object.assign(1, …)`) are omitted: CodeMode has no
|
||||
* wrapper objects, so a primitive target is a TypeError rather than a boxed result. `Override.js`
|
||||
* checks `Object.keys(result).length` instead of `Object.getOwnPropertyNames`. `target-Array.js`
|
||||
* omits its named-key (`-0`, `1.5`, `4294967295`), `length`, and Proxy assertions: arrays here hold
|
||||
* only indexed elements, so those keys are a TypeError (pinned below) rather than array properties.
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { CodeMode } from "../src/index.js"
|
||||
|
||||
const value = async (code: string) => {
|
||||
const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} }))
|
||||
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
|
||||
return result.value
|
||||
}
|
||||
|
||||
const throwsTypeError = (expression: string) =>
|
||||
value(`try { ${expression}; return "no throw" } catch (error) { return error.name }`)
|
||||
|
||||
describe("Object.keys Test262 parity", () => {
|
||||
test("test/built-ins/Object/keys/15.2.3.14-1-{1,2,3}.js: primitives are coerced", async () => {
|
||||
expect(await value(`return [Object.keys(0), Object.keys(true), Object.keys("abc")]`)).toEqual([
|
||||
[],
|
||||
[],
|
||||
["0", "1", "2"],
|
||||
])
|
||||
})
|
||||
|
||||
test("test/built-ins/Object/keys/15.2.3.14-1-{4,5}.js: null and undefined throw TypeError", async () => {
|
||||
expect(await throwsTypeError(`Object.keys(null)`)).toBe("TypeError")
|
||||
expect(await throwsTypeError(`Object.keys(undefined)`)).toBe("TypeError")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Object.entries and Object.values Test262 parity", () => {
|
||||
test("test/built-ins/Object/entries/primitive-strings.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const result = Object.entries('abc')
|
||||
return [Array.isArray(result), result.length, result[0][0], result[0][1], result[1][0], result[1][1], result[2][0], result[2][1]]
|
||||
`),
|
||||
).toEqual([true, 3, "0", "a", "1", "b", "2", "c"])
|
||||
})
|
||||
|
||||
test("test/built-ins/Object/entries/primitive-numbers.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
return [0, -0, Infinity, -Infinity, NaN, Math.PI].map((number) => Object.entries(number).length)
|
||||
`),
|
||||
).toEqual([0, 0, 0, 0, 0, 0])
|
||||
})
|
||||
|
||||
test("test/built-ins/Object/entries/primitive-booleans.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const trueResult = Object.entries(true)
|
||||
const falseResult = Object.entries(false)
|
||||
return [Array.isArray(trueResult), trueResult.length, Array.isArray(falseResult), falseResult.length]
|
||||
`),
|
||||
).toEqual([true, 0, true, 0])
|
||||
})
|
||||
|
||||
test("test/built-ins/Object/values/primitive-strings.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const result = Object.values('abc')
|
||||
return [Array.isArray(result), result.length, result[0], result[1], result[2]]
|
||||
`),
|
||||
).toEqual([true, 3, "a", "b", "c"])
|
||||
})
|
||||
|
||||
test("test/built-ins/Object/values/primitive-numbers.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
return [0, -0, Infinity, -Infinity, NaN, Math.PI].map((number) => Object.values(number).length)
|
||||
`),
|
||||
).toEqual([0, 0, 0, 0, 0, 0])
|
||||
})
|
||||
|
||||
test("test/built-ins/Object/values/primitive-booleans.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const trueResult = Object.values(true)
|
||||
const falseResult = Object.values(false)
|
||||
return [Array.isArray(trueResult), trueResult.length, Array.isArray(falseResult), falseResult.length]
|
||||
`),
|
||||
).toEqual([true, 0, true, 0])
|
||||
})
|
||||
})
|
||||
|
||||
describe("Object.hasOwn Test262 parity", () => {
|
||||
test("test/built-ins/Object/hasOwn/toobject_{null,undefined}.js", async () => {
|
||||
expect(await throwsTypeError(`Object.hasOwn(null, 'foo')`)).toBe("TypeError")
|
||||
expect(await throwsTypeError(`Object.hasOwn(undefined, 'foo')`)).toBe("TypeError")
|
||||
})
|
||||
|
||||
test("test/built-ins/Object/hasOwn/hasown_nonexistent.js", async () => {
|
||||
expect(await value(`const o = {}; return Object.hasOwn(o, "foo")`)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Object.assign Test262 parity", () => {
|
||||
test("test/built-ins/Object/assign/Source-String.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const target = new Object()
|
||||
const result = Object.assign(target, "123")
|
||||
return [result[0], result[1], result[2]]
|
||||
`),
|
||||
).toEqual(["1", "2", "3"])
|
||||
})
|
||||
|
||||
test("test/built-ins/Object/assign/Source-Null-Undefined.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const target = new Object()
|
||||
const result = Object.assign(target, undefined, null)
|
||||
return result === target
|
||||
`),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("test/built-ins/Object/assign/target-Array.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const target = [7, 8, 9]
|
||||
let result = Object.assign(target, [1])
|
||||
const first = [result === target, [...result]]
|
||||
const sparseArraySource = []
|
||||
sparseArraySource[2] = 3
|
||||
result = Object.assign(target, sparseArraySource)
|
||||
const second = [result === target, [...result]]
|
||||
result = Object.assign(target, { 4: 0 })
|
||||
return [...first, ...second, result === target, result.length, result[3] === undefined, result[4]]
|
||||
`),
|
||||
).toEqual([true, [1, 8, 9], true, [1, 8, 3], true, 5, true, 0])
|
||||
})
|
||||
|
||||
test("array targets accept only array indexes (deviation from target-Array.js)", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const target = [7]
|
||||
const out = []
|
||||
for (const source of [{ length: 0 }, { x: 1 }, { "1.5": 1 }, { "-0": 1 }, { ["__proto__"]: null }]) {
|
||||
try { Object.assign(target, source) } catch (error) { out.push(error.name) }
|
||||
}
|
||||
return [out, [...target], target.length, Object.keys(target)]
|
||||
`),
|
||||
).toEqual([["TypeError", "TypeError", "TypeError", "TypeError", "TypeError"], [7], 1, ["0"]])
|
||||
})
|
||||
|
||||
test("test/built-ins/Object/assign/Target-{Null,Undefined}.js", async () => {
|
||||
expect(await throwsTypeError(`Object.assign(null, { a: 1 })`)).toBe("TypeError")
|
||||
expect(await throwsTypeError(`Object.assign(undefined, { a: 1 })`)).toBe("TypeError")
|
||||
})
|
||||
|
||||
test("test/built-ins/Object/assign/Target-Object.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const target = { foo: 1 }
|
||||
const result = Object.assign(target, { a: 2 })
|
||||
return [result.foo, result.a]
|
||||
`),
|
||||
).toEqual([1, 2])
|
||||
})
|
||||
|
||||
test("test/built-ins/Object/assign/Override.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const target = { a: 1 }
|
||||
const result = Object.assign(target, "1a2c3", { a: "c" }, undefined, { b: 6 }, null, 125, { a: 5 })
|
||||
return [Object.keys(result).length, result.a, result[0], result[1], result[2], result[3], result[4], result.b]
|
||||
`),
|
||||
).toEqual([7, 5, "1", "a", "2", "c", "3", 6])
|
||||
})
|
||||
|
||||
test("test/built-ins/Object/assign/ObjectOverride-sameproperty.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const target = { a: 1 }
|
||||
const result = Object.assign(target, { a: 2 }, { a: "c" })
|
||||
return result.a
|
||||
`),
|
||||
).toBe("c")
|
||||
})
|
||||
})
|
||||
@@ -118,12 +118,9 @@ describe("H6: object spread of null/undefined is a no-op", () => {
|
||||
expect(await value(`const o = { a: 1 }; return { ...o, b: 2 }`)).toEqual({ a: 1, b: 2 })
|
||||
})
|
||||
|
||||
test("spreading an array or string into an object copies index keys, like JS", async () => {
|
||||
expect(await value(`return { ...[1,2], a: 1 }`)).toEqual({ 0: 1, 1: 2, a: 1 })
|
||||
expect(await value(`return { ..."ab", ...5, ...true, ...(() => 1), ...new Map([[1, 2]]) }`)).toEqual({
|
||||
0: "a",
|
||||
1: "b",
|
||||
})
|
||||
test("spreading an array into an object still errors", async () => {
|
||||
const err = await error(`return { ...[1,2], a: 1 }`)
|
||||
expect(err.kind).toBe("InvalidDataValue")
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -3,7 +3,7 @@ export * as ConfigInstructionPlugin from "./instruction.js"
|
||||
import { define } from "@opencode/plugin/effect/plugin"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { dirname, join, relative } from "path"
|
||||
import { dirname, join } from "path"
|
||||
import { Effect, PubSub, Semaphore, Stream } from "effect"
|
||||
import { Watcher } from "../../filesystem/watcher.js"
|
||||
import { InstructionDiscovery } from "../../instruction-discovery.js"
|
||||
@@ -34,8 +34,6 @@ export const Plugin = define({
|
||||
const home = yield* fs.resolve(global.home)
|
||||
const project = discovery.project && FSUtil.contains(root, start)
|
||||
const stop = FSUtil.contains(home, start) ? home : root
|
||||
const ancestors = project ? ancestorDirectories(start, stop) : []
|
||||
const boundary = ancestors.at(-1) ?? stop
|
||||
const globalFile = yield* fs.resolve(join(global.config, "AGENTS.md"))
|
||||
const loaded: { current: Loaded } = { current: { type: "available", files: [] } }
|
||||
|
||||
@@ -45,7 +43,7 @@ export const Plugin = define({
|
||||
const candidates = [
|
||||
...(discovery.global ? [globalFile] : []),
|
||||
...(project
|
||||
? ancestors
|
||||
? ancestorDirectories(start, stop)
|
||||
.map((directory) => join(directory, "AGENTS.md"))
|
||||
.filter((file) => discovery.global || file !== globalFile)
|
||||
: []),
|
||||
@@ -69,10 +67,7 @@ export const Plugin = define({
|
||||
|
||||
const projectSource = Effect.fn("ConfigInstructionPlugin.projectSource")(function* () {
|
||||
if (!project) return []
|
||||
const walked = yield* Effect.forEach(
|
||||
yield* fs.up({ targets: ["AGENTS.md"], start, stop: boundary }),
|
||||
fs.resolve,
|
||||
)
|
||||
const walked = yield* Effect.forEach(yield* fs.up({ targets: ["AGENTS.md"], start, stop }), fs.resolve)
|
||||
const discovered = new Set(walked.filter((file) => discovery.global || file !== globalFile))
|
||||
const files = yield* Effect.forEach(discovered, read, { concurrency: "unbounded" })
|
||||
if (files.some((file) => file === undefined)) return Instructions.unavailable
|
||||
@@ -136,9 +131,6 @@ export const Plugin = define({
|
||||
})
|
||||
|
||||
function ancestorDirectories(start: string, stop: string): string[] {
|
||||
const result = [start]
|
||||
if (relative(start, stop) === "") return result
|
||||
const parent = dirname(start)
|
||||
if (parent === start) throw new Error(`Instruction boundary ${stop} is not an ancestor of ${start}`)
|
||||
return [...result, ...ancestorDirectories(parent, stop)]
|
||||
if (start === stop) return [start]
|
||||
return [start, ...ancestorDirectories(dirname(start), stop)]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
@@ -487,10 +456,6 @@ export const layer = Layer.effect(
|
||||
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 request = prepared.request
|
||||
const provenance = SessionProviderContext.provenance(context.model)
|
||||
if (!provenance) return yield* reject("Provider compaction requires a stable, configured endpoint")
|
||||
@@ -606,7 +571,6 @@ export const layer = Layer.effect(
|
||||
const prepared = yield* compactionRequest(input, history.messages, [
|
||||
Message.user(buildPrompt(previous !== undefined, legacy)),
|
||||
])
|
||||
if (prepared.event.result) return yield* supplied(input, prepared.event.result, history.recent)
|
||||
// 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,
|
||||
|
||||
@@ -409,7 +409,6 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
yield* adapter.updateCompaction({
|
||||
...current,
|
||||
status: "completed",
|
||||
metadata: event.metadata ? { ...current.metadata, ...event.metadata } : current.metadata,
|
||||
reason: event.data.reason,
|
||||
model: event.data.model,
|
||||
providerState: event.data.providerState,
|
||||
|
||||
@@ -11,14 +11,7 @@ import {
|
||||
SystemPart,
|
||||
} from "@opencode/ai"
|
||||
import type { StreamOptions } from "@opencode/ai/route"
|
||||
import type {
|
||||
SessionCompaction,
|
||||
SessionContext,
|
||||
SessionGenerate,
|
||||
SessionRequest,
|
||||
SessionRequestKind,
|
||||
SessionTitle,
|
||||
} from "@opencode/plugin/effect/session"
|
||||
import type { SessionContext, SessionRequest, SessionRequestKind, SessionTitle } from "@opencode/plugin/effect/session"
|
||||
import type { Agent } from "@opencode/schema/agent"
|
||||
import type { Model } from "@opencode/schema/model"
|
||||
import type { Content } from "@opencode/schema/tool"
|
||||
@@ -185,8 +178,8 @@ type Definitions = PluginHooks.Domains["session"]["context"]["tools"]
|
||||
/** Builds the model request for each session flow. Each entry runs its own plugin hook. */
|
||||
export interface Interface {
|
||||
readonly primary: (input: Input) => Effect.Effect<Prepared<SessionContext>>
|
||||
readonly compaction: (input: Input) => Effect.Effect<Prepared<SessionCompaction>>
|
||||
readonly generate: (input: Input) => Effect.Effect<Prepared<SessionGenerate>>
|
||||
readonly compaction: (input: Input) => Effect.Effect<Prepared<SessionContext>>
|
||||
readonly generate: (input: Input) => Effect.Effect<Prepared<SessionContext>>
|
||||
readonly title: (input: Input) => Effect.Effect<Prepared<SessionTitle>>
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -43,7 +43,6 @@ export type MessagesInput = {
|
||||
sessionID: Session.ID
|
||||
limit?: number
|
||||
order?: "asc" | "desc"
|
||||
type?: SessionMessage.Type
|
||||
cursor?: {
|
||||
id: SessionMessage.ID
|
||||
direction: "previous" | "next"
|
||||
@@ -157,16 +156,13 @@ const layer = Layer.effect(
|
||||
? gt(SessionMessageTable.seq, anchor.seq)
|
||||
: lt(SessionMessageTable.seq, anchor.seq)
|
||||
: undefined
|
||||
const where = boundary
|
||||
? and(eq(SessionMessageTable.session_id, input.sessionID), boundary)
|
||||
: eq(SessionMessageTable.session_id, input.sessionID)
|
||||
const query = db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, input.sessionID),
|
||||
boundary,
|
||||
input.type === undefined ? undefined : eq(SessionMessageTable.type, input.type),
|
||||
),
|
||||
)
|
||||
.where(where)
|
||||
.orderBy(order === "asc" ? asc(SessionMessageTable.seq) : desc(SessionMessageTable.seq))
|
||||
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
|
||||
Effect.orDie,
|
||||
|
||||
@@ -175,7 +175,7 @@ describe("AISDKNative", () => {
|
||||
settings: { region: "us-east-1" },
|
||||
})
|
||||
expect(map("@ai-sdk/amazon-bedrock/mantle", { region: "us-east-1" }, "openai.gpt-oss-120b")).toEqual({
|
||||
package: "@opencode/ai/providers/amazon-bedrock/mantle/chat",
|
||||
package: "@opencode/ai/providers/amazon-bedrock/mantle/responses",
|
||||
settings: { region: "us-east-1" },
|
||||
})
|
||||
})
|
||||
@@ -287,7 +287,7 @@ describe("AISDKNative", () => {
|
||||
}
|
||||
|
||||
expect(map("@ai-sdk/amazon-bedrock/mantle", settings, "openai.gpt-oss-120b")).toEqual({
|
||||
package: "@opencode/ai/providers/amazon-bedrock/mantle/chat",
|
||||
package: "@opencode/ai/providers/amazon-bedrock/mantle/responses",
|
||||
settings: {
|
||||
apiKey: "token",
|
||||
baseURL: "https://mantle.test/v1",
|
||||
@@ -336,7 +336,7 @@ describe("AISDKNative", () => {
|
||||
"openai.gpt-oss-120b",
|
||||
),
|
||||
).toEqual({
|
||||
package: "@opencode/ai/providers/amazon-bedrock/mantle/chat",
|
||||
package: "@opencode/ai/providers/amazon-bedrock/mantle/responses",
|
||||
settings: {
|
||||
credentials: {
|
||||
accessKeyId: "key",
|
||||
|
||||
@@ -169,19 +169,19 @@ describe("ModelResolver", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("maps Bedrock Mantle GPT-OSS models to Chat and other models to Responses", () =>
|
||||
it.effect("maps Bedrock Mantle models to native Responses and safeguards to Chat", () =>
|
||||
Effect.gen(function* () {
|
||||
const credential = Credential.Key.make({ type: "key", key: "secret" })
|
||||
const responses = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"), {
|
||||
modelID: "openai.gpt-5.5",
|
||||
modelID: "openai.gpt-oss-120b",
|
||||
settings: { region: "us-east-2" },
|
||||
}),
|
||||
credential,
|
||||
)
|
||||
const chat = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"), {
|
||||
modelID: "openai.gpt-oss-20b",
|
||||
modelID: "openai.gpt-oss-safeguard-20b",
|
||||
settings: { region: "us-east-2" },
|
||||
}),
|
||||
credential,
|
||||
@@ -1041,7 +1041,7 @@ describe("ModelResolver", () => {
|
||||
["@ai-sdk/amazon-bedrock", "@opencode/ai/providers/amazon-bedrock", "api-model"],
|
||||
[
|
||||
"@ai-sdk/amazon-bedrock/mantle",
|
||||
"@opencode/ai/providers/amazon-bedrock/mantle/chat",
|
||||
"@opencode/ai/providers/amazon-bedrock/mantle/responses",
|
||||
"openai.gpt-oss-120b",
|
||||
],
|
||||
["@ai-sdk/azure", "@opencode/ai/providers/azure/responses", "api-model"],
|
||||
@@ -1271,7 +1271,7 @@ describe("ModelResolver", () => {
|
||||
expect(bedrock.route.id).toBe("bedrock-converse")
|
||||
expect(bedrock.route.defaults.generation).toEqual({ topP: 0.8 })
|
||||
expect(bedrock.route.defaults.http?.body).toEqual({ serviceTier: { type: "priority" } })
|
||||
expect(mantle.route.id).toBe("bedrock-mantle-chat")
|
||||
expect(mantle.route.id).toBe("bedrock-mantle-responses")
|
||||
expect(mantle.route.defaults.generation).toEqual({ topP: 0.6 })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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)],
|
||||
),
|
||||
@@ -442,65 +440,6 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("compaction hooks can supply the summary instead of the model", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
const db = (yield* Database.Service).db
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const sessionID = Session.ID.make("ses_hooked_compaction")
|
||||
const session = yield* insertSession(sessionID)
|
||||
const modelRequests = yield* SessionModelRequest.Service
|
||||
const messages = [
|
||||
{
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "user" as const,
|
||||
text: "Hooked compaction should see this conversation.",
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
},
|
||||
]
|
||||
let contexts = 0
|
||||
yield* hooks.register("session", "context", () => Effect.sync(() => contexts++))
|
||||
yield* hooks.register("session", "compaction", (event) =>
|
||||
Effect.sync(() => {
|
||||
expect(event.sessionID).toBe(sessionID)
|
||||
expect(event.agent).toBe(Agent.defaultID)
|
||||
expect(JSON.stringify(event.messages)).toContain("Hooked compaction should see this conversation.")
|
||||
event.result = { summary: "## Objective\n- hooked summary" }
|
||||
}),
|
||||
)
|
||||
|
||||
expect(
|
||||
yield* compaction.compactManual({
|
||||
session,
|
||||
resolveContext: () => Effect.succeed(loaded(session, messages)),
|
||||
prepare: modelRequests.compaction,
|
||||
messages,
|
||||
inputID: SessionMessage.ID.make("msg_hooked_compaction"),
|
||||
}),
|
||||
).toEqual({ status: "completed" })
|
||||
|
||||
expect(contexts).toBe(0)
|
||||
expect(requests).toEqual([])
|
||||
expect(yield* store.context(sessionID)).toMatchObject([
|
||||
{ type: "compaction", reason: "manual", summary: "## Objective\n- hooked summary", recent: "" },
|
||||
])
|
||||
expect(
|
||||
yield* db
|
||||
.select({ type: EventTable.type })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.aggregate_id, sessionID))
|
||||
.orderBy(asc(EventTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie),
|
||||
).toEqual([
|
||||
{ type: Bus.versionedType(SessionEvent.Compaction.Started.type, 1) },
|
||||
{ type: Bus.versionedType(SessionEvent.Compaction.Ended.type, 1) },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("manual compaction records model resolution failures without calling the model", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
import { expect } from "bun:test"
|
||||
import {
|
||||
LLMClient,
|
||||
LLMEvent,
|
||||
LLMResponse,
|
||||
LanguageModel,
|
||||
Message,
|
||||
SystemPart,
|
||||
ToolDefinition,
|
||||
type LLMRequest,
|
||||
} from "@opencode/ai"
|
||||
import { LLMClient, LLMEvent, LLMResponse, LanguageModel, ToolDefinition, type LLMRequest } from "@opencode/ai"
|
||||
import { OpenAIChat } from "@opencode/ai/protocols"
|
||||
import type { StreamOptions } from "@opencode/ai/route"
|
||||
import { Agent } from "@opencode/core/agent"
|
||||
@@ -47,7 +38,6 @@ import {
|
||||
import { SessionStore } from "@opencode/core/session/store"
|
||||
import { SkillInstructions } from "@opencode/core/skill/instructions"
|
||||
import { Plugin } from "@opencode/core/plugin"
|
||||
import { PluginHooks } from "@opencode/core/plugin/hooks"
|
||||
import { PluginSupervisor } from "@opencode/core/plugin/supervisor"
|
||||
import { Tool } from "@opencode/core/tool"
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
@@ -144,7 +134,6 @@ const it = testEffect(
|
||||
Agent.node,
|
||||
InstructionBuiltIns.node,
|
||||
SessionContext.node,
|
||||
PluginHooks.node,
|
||||
llmClient,
|
||||
]),
|
||||
[
|
||||
@@ -355,43 +344,6 @@ it.effect(
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"runs generate hooks instead of context hooks",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
requests.length = 0
|
||||
instruction = "Initial context"
|
||||
const { db, bus, instructions, session, instances } = yield* setup
|
||||
yield* InstructionState.prepare(db, bus, instructions, sessionID)
|
||||
const hooks = yield* PluginHooks.Service
|
||||
let contexts = 0
|
||||
yield* hooks.register("session", "context", () => Effect.sync(() => contexts++))
|
||||
yield* hooks.register("session", "generate", (event) =>
|
||||
Effect.sync(() => {
|
||||
expect(event.sessionID).toBe(sessionID)
|
||||
expect(event.agent).toBe(Agent.ID.make("build"))
|
||||
expect(Object.keys(event.tools)).toEqual(["lookup"])
|
||||
event.system.push(SystemPart.make("Answer briefly."))
|
||||
event.messages = [Message.user("[redacted]")]
|
||||
event.options.maxTokens = 32
|
||||
event.options.reasoningEffort = "low"
|
||||
}),
|
||||
)
|
||||
|
||||
yield* SessionGenerate.generate({ session, prompt: "Summarize privately" }).pipe(
|
||||
Effect.provideService(Instance.Service, instances),
|
||||
)
|
||||
|
||||
expect(contexts).toBe(0)
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.system.map((part) => part.text)).toContain("Answer briefly.")
|
||||
expect(userTexts(requests[0])).toEqual(["[redacted]"])
|
||||
expect(requests[0]?.generation).toEqual(expect.objectContaining({ maxTokens: 32 }))
|
||||
expect(requests[0]?.providerOptions).toEqual({ reasoningEffort: "low" })
|
||||
}),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"blocks unavailable initial instructions before generation",
|
||||
() =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4490,34 +4490,6 @@
|
||||
]
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "type",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"agent-switched",
|
||||
"model-switched",
|
||||
"location-switched",
|
||||
"user",
|
||||
"synthetic",
|
||||
"system",
|
||||
"skill",
|
||||
"shell",
|
||||
"assistant",
|
||||
"compaction"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Filter by message type before pagination. When omitted, all message types are returned. Pass the same type when following cursors."
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
@@ -4580,7 +4552,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Retrieve projected messages for a session, optionally filtered by type. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline, passing the same type filter on each page.",
|
||||
"description": "Retrieve projected messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.",
|
||||
"summary": "Get session messages"
|
||||
}
|
||||
},
|
||||
@@ -14271,9 +14243,6 @@
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"websocket": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"modelID": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -14382,9 +14351,6 @@
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"websocket": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"canonical": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -16331,9 +16297,6 @@
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"websocket": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"settings": {
|
||||
"type": "object"
|
||||
},
|
||||
@@ -17322,9 +17285,6 @@
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"websocket": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"settings": {
|
||||
"type": "object"
|
||||
},
|
||||
@@ -18296,12 +18256,6 @@
|
||||
},
|
||||
"providerContext": {
|
||||
"$ref": "#/components/schemas/Session.ProviderContext"
|
||||
},
|
||||
"cost": {
|
||||
"$ref": "#/components/schemas/Money.USD"
|
||||
},
|
||||
"tokens": {
|
||||
"$ref": "#/components/schemas/TokenUsage.Info"
|
||||
}
|
||||
},
|
||||
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
|
||||
@@ -18341,12 +18295,6 @@
|
||||
},
|
||||
"error": {
|
||||
"$ref": "#/components/schemas/Session.StructuredError"
|
||||
},
|
||||
"cost": {
|
||||
"$ref": "#/components/schemas/Money.USD"
|
||||
},
|
||||
"tokens": {
|
||||
"$ref": "#/components/schemas/TokenUsage.Info"
|
||||
}
|
||||
},
|
||||
"required": ["type", "id", "time", "status", "reason", "error"],
|
||||
|
||||
@@ -19,23 +19,6 @@ export const SessionMessagesQuery = Schema.Struct({
|
||||
"Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.",
|
||||
}),
|
||||
),
|
||||
type: Schema.optional(
|
||||
Schema.Literals([
|
||||
"agent-switched",
|
||||
"model-switched",
|
||||
"location-switched",
|
||||
"user",
|
||||
"synthetic",
|
||||
"system",
|
||||
"skill",
|
||||
"shell",
|
||||
"assistant",
|
||||
"compaction",
|
||||
] satisfies ReadonlyArray<SessionMessage.Type>),
|
||||
).annotate({
|
||||
description:
|
||||
"Filter by message type before pagination. When omitted, all message types are returned. Pass the same type when following cursors.",
|
||||
}),
|
||||
}).annotate({ identifier: "SessionMessagesQuery" })
|
||||
|
||||
export const MessageGroup = HttpApiGroup.make("server.message")
|
||||
@@ -56,7 +39,7 @@ export const MessageGroup = HttpApiGroup.make("server.message")
|
||||
identifier: "v2.message.list",
|
||||
summary: "Get session messages",
|
||||
description:
|
||||
"Retrieve projected messages for a session, optionally filtered by type. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline, passing the same type filter on each page.",
|
||||
"Retrieve projected messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -44,7 +44,6 @@ export const MessageHandler = HttpApiBuilder.group(Api, "server.message", (handl
|
||||
sessionID: ctx.params.sessionID,
|
||||
limit: ctx.query.limit ?? DefaultMessagesLimit,
|
||||
order,
|
||||
type: ctx.query.type,
|
||||
cursor: decoded ? { id: decoded.id, direction: decoded.direction } : undefined,
|
||||
})
|
||||
.pipe(
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { OpenCode, type SessionMessageInfo } from "@opencode/client"
|
||||
import { Session } from "@opencode/schema/session"
|
||||
import { Effect } from "effect"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { ServerFetch } from "../src/fetch"
|
||||
|
||||
const messages: SessionMessageInfo[] = [
|
||||
{ id: "msg_z", type: "user", text: "First request", time: { created: 300 } },
|
||||
{
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "test" },
|
||||
content: [{ type: "text", text: "First answer" }],
|
||||
finish: "stop",
|
||||
time: { created: 400, completed: 500 },
|
||||
},
|
||||
{ id: "msg_b", type: "user", text: "Second request", time: { created: 100 } },
|
||||
{ id: "msg_synthetic", type: "synthetic", text: "Background completion", time: { created: 200 } },
|
||||
{
|
||||
id: "msg_compaction",
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
reason: "manual",
|
||||
summary: "Conversation summary",
|
||||
recent: "",
|
||||
time: { created: 700 },
|
||||
},
|
||||
{ id: "msg_x", type: "user", text: "Third request", time: { created: 600 } },
|
||||
{ id: "msg_system", type: "system", text: "Updated instructions", time: { created: 800 } },
|
||||
{ id: "msg_a", type: "user", text: "Fourth request", time: { created: 500 } },
|
||||
]
|
||||
|
||||
const setup = Effect.gen(function* () {
|
||||
const handler = yield* ServerFetch.make({
|
||||
app: { version: "test" },
|
||||
database: { path: ":memory:" },
|
||||
config: { project: false },
|
||||
models: { fetch: false },
|
||||
fs: { filewatcher: false },
|
||||
})
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://opencode.local",
|
||||
fetch: Object.assign((input: string | URL | Request, init?: RequestInit) => handler(new Request(input, init)), {
|
||||
preconnect: fetch.preconnect,
|
||||
}),
|
||||
})
|
||||
const session = yield* Effect.promise(async () => {
|
||||
const template = await api.session.create({ title: "Message filtering" })
|
||||
return api.session.import({ info: { ...template, id: Session.ID.create() }, messages })
|
||||
})
|
||||
return { api, handler, sessionID: session.id }
|
||||
})
|
||||
|
||||
it.live("filters message types before paginating in either direction through the generated client", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* setup
|
||||
yield* Effect.promise(async () => {
|
||||
const input = { sessionID: fixture.sessionID, type: "user", limit: 2 } as const
|
||||
// Omission retains the full transcript, in durable sequence rather than timestamp or ID order.
|
||||
expect(
|
||||
(await fixture.api.message.list({ sessionID: fixture.sessionID })).data.map((message) => message.id),
|
||||
).toEqual(messages.toReversed().map((message) => message.id))
|
||||
for (const order of ["asc", "desc"] as const) {
|
||||
const ids = order === "asc" ? ["msg_z", "msg_b", "msg_x", "msg_a"] : ["msg_a", "msg_x", "msg_b", "msg_z"]
|
||||
const first = await fixture.api.message.list({ ...input, order })
|
||||
expect(first.data.map((message) => message.id)).toEqual(ids.slice(0, 2))
|
||||
if (!first.cursor.next) throw new Error("Expected a next cursor")
|
||||
const second = await fixture.api.message.list({ ...input, cursor: first.cursor.next })
|
||||
expect(second.data.map((message) => message.id)).toEqual(ids.slice(2))
|
||||
if (!second.cursor.previous || !second.cursor.next) throw new Error("Expected previous and next cursors")
|
||||
const previous = await fixture.api.message.list({ ...input, cursor: second.cursor.previous })
|
||||
expect(previous.data).toEqual(first.data)
|
||||
const end = await fixture.api.message.list({ ...input, cursor: second.cursor.next })
|
||||
expect(end).toEqual({ data: [], cursor: { previous: null, next: null } })
|
||||
}
|
||||
expect((await fixture.api.message.list({ sessionID: fixture.sessionID, type: "compaction" })).data).toEqual([
|
||||
messages[4],
|
||||
])
|
||||
expect(
|
||||
(await fixture.api.message.list({ sessionID: fixture.sessionID, type: "assistant", limit: 1 })).data,
|
||||
).toEqual([messages[1]])
|
||||
expect((await fixture.api.message.list({ sessionID: fixture.sessionID, type: "shell" })).data).toEqual([])
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("rejects unknown message type filters at the HTTP boundary", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* setup
|
||||
yield* Effect.promise(async () => {
|
||||
for (const type of ["unknown", "tool", "User", ""]) {
|
||||
const response = await fixture.handler(
|
||||
new Request(`http://opencode.local/api/session/${fixture.sessionID}/message?type=${type}`),
|
||||
)
|
||||
expect(response.status).toBe(400)
|
||||
expect(await response.json()).toMatchObject({ _tag: "InvalidRequestError" })
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -13,7 +13,6 @@ import { Plugin } from "@opencode/core/plugin"
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { SessionRunnerModel } from "@opencode/core/session/runner/model"
|
||||
import { define } from "@opencode/plugin/effect/plugin"
|
||||
import type { SessionHooks } from "@opencode/plugin/effect/session"
|
||||
import { Agent } from "@opencode/schema/agent"
|
||||
import { Location } from "@opencode/schema/location"
|
||||
import { AbsolutePath } from "@opencode/schema/schema"
|
||||
@@ -93,12 +92,11 @@ it.live(
|
||||
event.prompt.text += ` [${config.tool}]`
|
||||
}),
|
||||
)
|
||||
const tune = (event: SessionHooks["context"]) =>
|
||||
yield* ctx.session.hook("context", (event) =>
|
||||
Effect.sync(() => {
|
||||
event.options.temperature = config.temperature
|
||||
})
|
||||
yield* ctx.session.hook("context", tune)
|
||||
yield* ctx.session.hook("generate", tune)
|
||||
}),
|
||||
)
|
||||
yield* ctx.permission.hook("evaluate", (event) =>
|
||||
Effect.sync(() => {
|
||||
event.effect = event.action === "instance-test" ? "ask" : "allow"
|
||||
|
||||
@@ -43,13 +43,14 @@ story("merges follow-up patches into one stack with a distinct file count", asyn
|
||||
await root.getByRole("button", { name: "Start follow-up patch" }).click()
|
||||
const usage = group.locator('[data-component="context-tool-group-trigger"] [data-slot="context-tool-group-usage"]')
|
||||
await expect(usage.locator('[data-slot="context-tool-group-prefix"]')).toHaveText("Used")
|
||||
await expect(usage.locator('[data-slot="context-tool-group-count"]')).toHaveText("3")
|
||||
await expect(usage.locator('[data-slot="context-tool-group-count"]')).toHaveText("2")
|
||||
await expect(patches).toHaveCount(1)
|
||||
await expect(patches.getByText("2 files", { exact: true })).toBeVisible()
|
||||
await root.getByRole("button", { name: "Finish follow-up patch" }).click()
|
||||
await expect(usage.locator('[data-slot="context-tool-group-count"]')).toHaveText("4")
|
||||
await expect(patches).toHaveCount(1)
|
||||
await expect(patches.getByText("3 files", { exact: true })).toBeVisible()
|
||||
await expect(patches.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts"])
|
||||
await expect(patches.getByText("4 files", { exact: true })).toBeVisible()
|
||||
await expect(patches.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts", "d.ts"])
|
||||
await expect(first).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(patches.locator('[data-component="file"]')).toBeVisible()
|
||||
await group.screenshot({ path: info.outputPath("merged.png") })
|
||||
@@ -61,7 +62,13 @@ for (const separator of ["shell", "error", "reasoning"]) {
|
||||
await root.getByRole("button", { name: "Finish follow-up patch" }).click()
|
||||
const group = root.locator('[data-component="collapsed-tool-group"]')
|
||||
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(2)
|
||||
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "a.ts", "c.ts"])
|
||||
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText([
|
||||
"a.ts",
|
||||
"b.ts",
|
||||
"a.ts",
|
||||
"c.ts",
|
||||
"d.ts",
|
||||
])
|
||||
if (separator === "error") await expect(group.locator('[data-kind="tool-error-card"]')).toBeVisible()
|
||||
})
|
||||
}
|
||||
@@ -72,8 +79,14 @@ story("does not retain patch files in the wrong batch when thoughts are shown",
|
||||
await root.getByRole("button", { name: "Finish follow-up patch" }).click()
|
||||
const group = root.locator('[data-component="collapsed-tool-group"]')
|
||||
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(1)
|
||||
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts"])
|
||||
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts", "d.ts"])
|
||||
await root.getByRole("button", { name: "Show thoughts", exact: true }).click()
|
||||
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(2)
|
||||
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "a.ts", "c.ts"])
|
||||
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText([
|
||||
"a.ts",
|
||||
"b.ts",
|
||||
"a.ts",
|
||||
"c.ts",
|
||||
"d.ts",
|
||||
])
|
||||
})
|
||||
|
||||
@@ -29,16 +29,16 @@ for (const open of [true, false]) {
|
||||
await expect(second).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(diff).toBeVisible()
|
||||
const original = await patch.elementHandle()
|
||||
for (const count of [3, 4]) {
|
||||
for (const call of [1, 2]) {
|
||||
await root.getByRole("button", { name: "Append tool call", exact: true }).click()
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
|
||||
).toHaveText("Shell, Patch")
|
||||
await expect(trigger).toHaveAccessibleName(`Used ${count} Shell, Patch`)
|
||||
await expect(trigger).toHaveAccessibleName("Used 2 Shell, Patch")
|
||||
await expect(diff).toBeVisible()
|
||||
await root
|
||||
.locator('[data-component="session-timeline"]')
|
||||
.screenshot({ path: info.outputPath(`append-${count}.png`) })
|
||||
.screenshot({ path: info.outputPath(`append-${call}.png`) })
|
||||
await expect(shell).toHaveAttribute("aria-expanded", String(open))
|
||||
await expect(first).toHaveAttribute("aria-expanded", String(open))
|
||||
await expect(second).toHaveAttribute("aria-expanded", "true")
|
||||
|
||||
@@ -132,7 +132,10 @@ export const PatchFollowUps = {
|
||||
state.phase === "running" ? "running" : "completed",
|
||||
{},
|
||||
{
|
||||
metadata: state.phase === "running" ? {} : { files: [file("src/a.ts", 1, 2), file("src/c.ts", 0, 1)] },
|
||||
metadata:
|
||||
state.phase === "running"
|
||||
? {}
|
||||
: { files: [file("src/a.ts", 1, 2), file("src/c.ts", 0, 1), file("src/d.ts", 0, 1)] },
|
||||
},
|
||||
),
|
||||
]),
|
||||
|
||||
@@ -533,6 +533,15 @@ export function CurrentContextToolGroup(props: {
|
||||
),
|
||||
].join(", "),
|
||||
)
|
||||
const patchedFiles = createMemo(() =>
|
||||
patchFileGroups(
|
||||
tools().flatMap((tool) => {
|
||||
if (tool.name !== "patch" || tool.state.status === "error") return []
|
||||
const files = currentToolMetadata(tool).files
|
||||
return Array.isArray(files) ? files : []
|
||||
}),
|
||||
).length,
|
||||
)
|
||||
const label = createMemo(() => {
|
||||
const thoughts = props.parts.filter((part) => part.type === "reasoning").length
|
||||
if (!names() && !thoughts) {
|
||||
@@ -540,7 +549,8 @@ export function CurrentContextToolGroup(props: {
|
||||
return { text: title, title, before: "", count: "", between: "", after: "" }
|
||||
}
|
||||
const title = names() || i18n.plural("ui.messagePart.context.thought", thoughts)
|
||||
const count = props.parts.filter((part) => part.type === "tool" || part.type === "shell").length || thoughts
|
||||
const count =
|
||||
patchedFiles() || props.parts.filter((part) => part.type === "tool" || part.type === "shell").length || thoughts
|
||||
const text = i18n.plural("ui.messagePart.tools.used", count, { tools: title })
|
||||
const index = text.indexOf(title)
|
||||
const before = text.slice(0, index).trim()
|
||||
|
||||
@@ -4490,34 +4490,6 @@
|
||||
]
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "type",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"agent-switched",
|
||||
"model-switched",
|
||||
"location-switched",
|
||||
"user",
|
||||
"synthetic",
|
||||
"system",
|
||||
"skill",
|
||||
"shell",
|
||||
"assistant",
|
||||
"compaction"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Filter by message type before pagination. When omitted, all message types are returned. Pass the same type when following cursors."
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
@@ -4580,7 +4552,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Retrieve projected messages for a session, optionally filtered by type. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline, passing the same type filter on each page.",
|
||||
"description": "Retrieve projected messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.",
|
||||
"summary": "Get session messages"
|
||||
}
|
||||
},
|
||||
@@ -14271,9 +14243,6 @@
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"websocket": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"modelID": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -14382,9 +14351,6 @@
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"websocket": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"canonical": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -16331,9 +16297,6 @@
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"websocket": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"settings": {
|
||||
"type": "object"
|
||||
},
|
||||
@@ -17322,9 +17285,6 @@
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"websocket": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"settings": {
|
||||
"type": "object"
|
||||
},
|
||||
@@ -18296,12 +18256,6 @@
|
||||
},
|
||||
"providerContext": {
|
||||
"$ref": "#/components/schemas/Session.ProviderContext"
|
||||
},
|
||||
"cost": {
|
||||
"$ref": "#/components/schemas/Money.USD"
|
||||
},
|
||||
"tokens": {
|
||||
"$ref": "#/components/schemas/TokenUsage.Info"
|
||||
}
|
||||
},
|
||||
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
|
||||
@@ -18341,12 +18295,6 @@
|
||||
},
|
||||
"error": {
|
||||
"$ref": "#/components/schemas/Session.StructuredError"
|
||||
},
|
||||
"cost": {
|
||||
"$ref": "#/components/schemas/Money.USD"
|
||||
},
|
||||
"tokens": {
|
||||
"$ref": "#/components/schemas/TokenUsage.Info"
|
||||
}
|
||||
},
|
||||
"required": ["type", "id", "time", "status", "reason", "error"],
|
||||
|
||||
@@ -4490,34 +4490,6 @@
|
||||
]
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "type",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"agent-switched",
|
||||
"model-switched",
|
||||
"location-switched",
|
||||
"user",
|
||||
"synthetic",
|
||||
"system",
|
||||
"skill",
|
||||
"shell",
|
||||
"assistant",
|
||||
"compaction"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Filter by message type before pagination. When omitted, all message types are returned. Pass the same type when following cursors."
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
@@ -4580,7 +4552,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Retrieve projected messages for a session, optionally filtered by type. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline, passing the same type filter on each page.",
|
||||
"description": "Retrieve projected messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.",
|
||||
"summary": "Get session messages"
|
||||
}
|
||||
},
|
||||
@@ -14271,9 +14243,6 @@
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"websocket": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"modelID": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -14382,9 +14351,6 @@
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"websocket": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"canonical": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -16331,9 +16297,6 @@
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"websocket": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"settings": {
|
||||
"type": "object"
|
||||
},
|
||||
@@ -17322,9 +17285,6 @@
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
},
|
||||
"websocket": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"settings": {
|
||||
"type": "object"
|
||||
},
|
||||
@@ -18296,12 +18256,6 @@
|
||||
},
|
||||
"providerContext": {
|
||||
"$ref": "#/components/schemas/Session.ProviderContext"
|
||||
},
|
||||
"cost": {
|
||||
"$ref": "#/components/schemas/Money.USD"
|
||||
},
|
||||
"tokens": {
|
||||
"$ref": "#/components/schemas/TokenUsage.Info"
|
||||
}
|
||||
},
|
||||
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
|
||||
@@ -18341,12 +18295,6 @@
|
||||
},
|
||||
"error": {
|
||||
"$ref": "#/components/schemas/Session.StructuredError"
|
||||
},
|
||||
"cost": {
|
||||
"$ref": "#/components/schemas/Money.USD"
|
||||
},
|
||||
"tokens": {
|
||||
"$ref": "#/components/schemas/TokenUsage.Info"
|
||||
}
|
||||
},
|
||||
"required": ["type", "id", "time", "status", "reason", "error"],
|
||||
|
||||
@@ -1089,9 +1089,7 @@ effect: (ctx) =>
|
||||
|
||||
### Sessions
|
||||
|
||||
Modify assembled system instructions, messages, or tools immediately before model dispatch. `context` runs for the
|
||||
agent loop; `compaction`, `generate`, and `title` run for those auxiliary requests. `compaction` and `title` accept a
|
||||
`result` that skips the model call.
|
||||
Modify assembled system instructions, messages, or tools immediately before model dispatch.
|
||||
|
||||
```ts
|
||||
effect: (ctx) =>
|
||||
@@ -1103,11 +1101,6 @@ effect: (ctx) =>
|
||||
delete event.tools.write
|
||||
}),
|
||||
)
|
||||
yield* session.hook("compaction", (event) =>
|
||||
Effect.map(summarize(event.messages), (summary) => {
|
||||
event.result = { summary }
|
||||
}),
|
||||
)
|
||||
}),
|
||||
```
|
||||
|
||||
@@ -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,17 +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. 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) => {
|
||||
@@ -1216,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:
|
||||
|
||||
@@ -1332,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
|
||||
@@ -1352,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
|
||||
@@ -1369,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