Compare commits

..
Author SHA1 Message Date
Hona b2af928895 fix(session-ui): count patched files in tool summaries
Grouped tool summaries used the raw tool-call count, so repeated patch calls reported the number of invocations instead of the distinct files changed. Derive the count from successful patch metadata while keeping the existing fallback for other tools.
2026-09-09 23:39:58 +00:00
Luke Parker 0f67a15f3b fix(app): reduce cold and warm session load work (#48223) 2026-09-10 09:04:50 +10:00
Luke Parker 08ac1e168c fix(app): hide outgoing browser when switching sessions (#48243) 2026-09-10 08:50:03 +10:00
usrnk1andHona eb37a7ebc7 feat(desktop): polish branch search and session spacing (#48150)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-09-10 08:40:16 +10:00
opencode-agent[bot]andiamdavidhill bf4522ed46 fix(app): prevent settings project card clipping (#45366)
Co-authored-by: iamdavidhill <1879069+iamdavidhill@users.noreply.github.com>
2026-09-10 08:10:50 +10:00
usrnk1 571c3c4f00 feat(desktop): show compaction progress and outcomes (#48152) 2026-09-10 08:09:10 +10:00
Aiden Cline 50ed7c41ef refactor(codemode): treat prototype-named keys as ordinary data (#48218) 2026-09-09 16:54:55 -05:00
usrnk1 0bbf29fea6 feat(desktop): use base menu selection styling (#47786) 2026-09-10 07:54:41 +10:00
opencode-agent[bot]andrekram1-node a0a0e3271c fix(core): make responses websockets opt-in (#48231)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-09 16:54:08 -05:00
opencode-agent[bot]andiamdavidhill 7ed5223d5e fix(session-ui): align retry icon with label (#47859)
Co-authored-by: iamdavidhill <1879069+iamdavidhill@users.noreply.github.com>
2026-09-10 07:53:29 +10:00
Aiden Cline 1bd85d926b fix(ai): mark replayed assistant messages completed (#48221) 2026-09-09 16:38:05 -05:00
Dax c45e425e12 fix(server): require authentication for frontend requests (#48217) 2026-09-09 16:38:26 -04:00
Dax 7fb79388a4 feat(cli): select individual debug paths (#48216) 2026-09-09 16:11:21 -04:00
Aiden Cline 7f2510c5ca fix(codemode): name the received value in data diagnostics and document intentional gaps (#48211) 2026-09-09 14:48:51 -05:00
Aiden Cline 9ae6b21f6a feat(plugin): add session title hook and request options bag (#47663) 2026-09-09 14:14:23 -05:00
opencode-agent[bot]andvimtor c7dd0c8278 fix(tui): match upgrade alias to update command (#48204)
Co-authored-by: vimtor <36263538+vimtor@users.noreply.github.com>
2026-09-09 20:16:23 +02:00
Aiden Cline 85dff53a1f fix(codemode): label supported and unsupported syntax in the hint (#48200) 2026-09-09 12:33:22 -05:00
Aiden Cline 297019e321 fix(codemode): report non-constructible new callees as TypeError (#48083) 2026-09-09 12:22:44 -05:00
Aiden Cline 95503c1773 fix(core): send reasoning.effort for GPT-5.6+ on Bedrock Converse (#48195) 2026-09-09 12:02:31 -05:00
Aiden Cline ba1448325a fix(core): exclude invalid Bedrock entries from models.dev imports (#48081) 2026-09-09 11:36:14 -05:00
be2582f316 feat(plugin): decompose tab controls (#48129)
Co-authored-by: vimtor <36263538+vimtor@users.noreply.github.com>
Co-authored-by: Victor Navarro <vn4varro@gmail.com>
2026-09-09 18:14:33 +02:00
opencode-agent[bot]andjlongster c0cb1c7a91 fix(tui): limit shell list commands to one line (#48184)
Co-authored-by: jlongster <17031+jlongster@users.noreply.github.com>
2026-09-09 11:09:32 -04:00
opencode-agent[bot]andjlongster e10408b219 fix(cli): ignore upgrade cleanup failures (#48178)
Co-authored-by: jlongster <17031+jlongster@users.noreply.github.com>
2026-09-09 10:51:17 -04:00
Shoubhit Dash bdc143c1d5 feat(core): enable the responses websocket by default (#48140) 2026-09-09 20:13:31 +05:30
Shoubhit Dash d52380024d fix(core): drop gpt-5.4 models from the Codex allowlist (#48141) 2026-09-09 20:04:46 +05:30
Kit Langton f1eed8bf11 fix(core): preserve legacy markdown agent variants 2026-09-09 10:02:03 -04:00
158 changed files with 3468 additions and 1031 deletions
@@ -153,6 +153,15 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
const rejection = code(event)
if (rejection === "previous_response_not_found") return rejected(observation, "retry-full")
if (rejection === "websocket_connection_limit_reached") return rejected(observation, "rotate-and-retry-full")
// Only the continuation distinguishes an incremental send from a full one, so an unclassified
// invalid request there is retried full; Codex reports a stale previous_response_id that way, with
// no code. Classified failures such as context overflow keep their runner-owned recovery.
if (
create.mode === "incremental" &&
observation.error.reason._tag === "InvalidRequest" &&
observation.error.reason.classification === undefined
)
return rejected(observation, "retry-full")
}
if (observation.type !== "completed") return observation
// A trigger installs a different context window. Clear the append baseline, retaining the socket.
+2 -1
View File
@@ -655,7 +655,8 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
type: "message" as const,
...(group.id === undefined ? {} : { id: group.id }),
role: "assistant" as const,
status: metadata?.status,
// Replayed text is a finished input item, even if generation was cut short.
status: "completed",
content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
...(group.phase === undefined ? {} : { phase: group.phase }),
})),
+5 -2
View File
@@ -115,8 +115,11 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
}
const onAbort = () => {
cleanup()
if (ws.readyState !== globalThis.WebSocket.CLOSED && ws.readyState !== globalThis.WebSocket.CLOSING)
ws.close(1000)
if (ws.readyState === globalThis.WebSocket.CLOSED || ws.readyState === globalThis.WebSocket.CLOSING) return
// Node's ws reports an aborted handshake as an error event on the next tick; with no listener left
// after cleanup, EventEmitter would throw it as an uncaught exception.
ws.addEventListener("error", () => {}, { once: true })
ws.close(1000)
}
const onOpen = () => {
cleanup()
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -81,7 +81,7 @@
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Alpha.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Beta.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Follow the user's exact reply instruction.\"}"
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"text\":\"Alpha.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Beta.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Follow the user's exact reply instruction.\"}"
},
{
"direction": "server",
@@ -91,7 +91,7 @@
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Ready.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Follow the user's exact reply instruction.\"}"
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"text\":\"Ready.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Follow the user's exact reply instruction.\"}"
},
{
"direction": "server",
File diff suppressed because one or more lines are too long
@@ -23,7 +23,7 @@ it.effect("conversation lowering excludes generation settings and tool definitio
instructions: "Keep the context",
input: [
{ role: "user", content: [{ type: "input_text", text: "hello" }] },
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "hi" }] },
{ type: "message", role: "assistant", status: "completed", content: [{ type: "output_text", text: "hi" }] },
],
})
}),
@@ -0,0 +1,120 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent, Message } from "../../src/index.js"
import { OpenAI } from "../../src/providers.js"
import { configure } from "../../src/providers/openai-compatible-responses.js"
import { compileRequest, LLMClient } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
for (const model of [
OpenAI.configure({ apiKey: "test-key" }).responses("example-model"),
configure({ apiKey: "test-key", baseURL: "https://responses.example.test/v1" }).model("example-model"),
]) {
describe(`${model.route.protocol} message replay`, () => {
const key = model.route.providerMetadataKey ?? "openresponses"
it.effect("marks assistant text completed regardless of stored status", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
...[undefined, "in_progress", "incomplete", "completed"].map((status, index) =>
Message.make({
role: "assistant",
providerMetadata: { [key]: { status } },
content: [
{
type: "text",
text: `Saved ${index}`,
providerMetadata: { [key]: { itemId: `msg_${index}`, phase: "commentary", status } },
},
{
type: "text",
text: `Final ${index}`,
providerMetadata: { [key]: { itemId: `msg_final_${index}`, phase: "final_answer", status } },
},
],
}),
),
Message.make({
role: "user",
content: [{ type: "text", text: "Continue" }],
providerMetadata: { [key]: { status: "incomplete" } },
}),
],
}),
)
expect(prepared.body.input).toEqual([
...[0, 1, 2, 3].flatMap((index) => [
{
type: "message",
role: "assistant",
id: `msg_${index}`,
phase: "commentary",
status: "completed",
content: [{ type: "output_text", text: `Saved ${index}` }],
},
{
type: "message",
role: "assistant",
id: `msg_final_${index}`,
phase: "final_answer",
status: "completed",
content: [{ type: "output_text", text: `Final ${index}` }],
},
]),
{ role: "user", status: "incomplete", content: [{ type: "input_text", text: "Continue" }] },
])
}),
)
it.effect("replays truncated text as completed while retaining the response finish reason", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Respond" })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
item: { type: "message", id: "msg_partial", status: "in_progress" },
},
{ type: "response.output_text.delta", item_id: "msg_partial", delta: "The next step is" },
{
type: "response.output_item.done",
item: {
type: "message",
id: "msg_partial",
status: "incomplete",
content: [{ type: "output_text", text: "The next step is" }],
},
},
{
type: "response.incomplete",
response: { status: "incomplete", incomplete_details: { reason: "max_output_tokens" } },
},
),
),
),
)
expect(response.finishReason.normalized).toBe("length")
expect(response.events.filter(LLMEvent.is.textEnd)).toHaveLength(1)
const prepared = yield* compileRequest(
LLM.request({ model, messages: [response.message, Message.user("Continue")] }),
)
expect(prepared.body.input).toEqual([
{
type: "message",
role: "assistant",
id: "msg_partial",
status: "completed",
content: [{ type: "output_text", text: "The next step is" }],
},
{ role: "user", content: [{ type: "input_text", text: "Continue" }] },
])
}),
)
})
}
@@ -91,7 +91,7 @@ describe("Open Responses-compatible route", () => {
expect(prepared.body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
{ role: "developer", content: "Operator update." },
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "After." }] },
{ type: "message", role: "assistant", status: "completed", content: [{ type: "output_text", text: "After." }] },
])
}),
)
@@ -299,23 +299,27 @@ describe("Open Responses-compatible route", () => {
type: "message",
id: "history_1",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "Kept." }],
},
{
type: "message",
id: `history_${"a".repeat(64)}`,
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "Long." }],
},
{
type: "message",
id: "provider_value/with+symbols",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "Opaque." }],
},
{
type: "message",
role: "assistant",
status: "completed",
content: [
{ type: "output_text", text: "No suffix." },
{ type: "output_text", text: "No prefix." },
@@ -856,6 +860,7 @@ describe("Open Responses-compatible route", () => {
type: "message",
id: "msg_refusal",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "I can't help with that." }],
},
])
@@ -171,7 +171,7 @@ describe("OpenAI Responses WebSocket recorded", () => {
instructions: "Follow the user's exact reply instruction.",
input: [
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Alpha." }] },
{ role: "assistant", content: [{ type: "output_text", text: "Alpha." }] },
{ role: "assistant", status: "completed", content: [{ type: "output_text", text: "Alpha." }] },
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Beta." }] },
],
})
@@ -208,7 +208,7 @@ describe("OpenAI Responses WebSocket recorded", () => {
instructions: "Follow the user's exact reply instruction.",
input: [
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Ready." }] },
{ role: "assistant", content: [{ type: "output_text", text: "Ready." }] },
{ role: "assistant", status: "completed", content: [{ type: "output_text", text: "Ready." }] },
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Recovered." }] },
],
})
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { ConfigProvider, Effect, Layer, Ref, Stream } from "effect"
import { ConfigProvider, Effect, Layer, Ref, Schema, Stream } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import {
LLM,
@@ -30,6 +30,7 @@ import * as Azure from "../../src/providers/azure.js"
import * as OpenAI from "../../src/providers/openai.js"
import * as XAI from "../../src/providers/xai.js"
import * as OpenAIResponses from "../../src/protocols/openai-responses.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { OpenResponsesContinuation } from "../../src/protocols/open-responses-continuation.js"
import * as ProviderShared from "../../src/protocols/shared.js"
import { continuationRequest, nativeOpenAIResponsesContinuation } from "../continuation-scenarios.js"
@@ -69,14 +70,34 @@ const baseChannelDriver = (message: string): WebSocketChannelDriver => ({
},
})
const continuationDriver = (request: Readonly<Record<string, unknown>>) => {
/** Classifies error frames the way the production channel does, so recovery can read the canonical reason. */
const classifyingChannelDriver = (message: string): WebSocketChannelDriver => {
const base = baseChannelDriver(message)
const decodeEvent = Schema.decodeUnknownSync(OpenResponses.protocol.stream.event)
return {
...base,
observe: (create, frame) =>
base.observe(create, frame).pipe(
Effect.map((observation) =>
observation.type === "provider-failure"
? {
...observation,
error: OpenResponses.providerFailure(decodeEvent(frame), "stream error", frame),
}
: observation,
),
),
}
}
const continuationDriver = (request: Readonly<Record<string, unknown>>, base = baseChannelDriver) => {
const message = ProviderShared.encodeJson(request)
return OpenResponsesContinuation.driver({
id: "openai-responses",
name: "OpenAI Responses",
request,
message,
base: baseChannelDriver(message),
base: base(message),
})
}
@@ -385,7 +406,7 @@ describe("OpenAI Responses route", () => {
expect(prepared.body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
{ role: "developer", content: "Operator update." },
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "After." }] },
{ type: "message", role: "assistant", status: "completed", content: [{ type: "output_text", text: "After." }] },
])
}),
)
@@ -852,6 +873,53 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("retries an incremental send in full when the provider rejects it without a code", () =>
Effect.gen(function* () {
const firstRequest = {
type: "response.create",
model: "gpt-5.2",
store: false,
input: [{ role: "user", content: [{ type: "input_text", text: "First" }] }],
}
const first = continuationDriver(firstRequest, classifyingChannelDriver)
const saved = checkpoint(
yield* first.observe(
yield* first.create(undefined),
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
),
)
const second = continuationDriver(
{
...firstRequest,
input: [...firstRequest.input, { role: "user", content: [{ type: "input_text", text: "Second" }] }],
},
classifyingChannelDriver,
)
// Codex reports a stale previous_response_id as a plain invalid_request_error.
const stale = ProviderShared.encodeJson({
type: "error",
error: { type: "invalid_request_error", message: "Invalid `previous_response_id`." },
})
const incremental = yield* second.create(saved)
expect(incremental.mode).toBe("incremental")
expect(yield* second.observe(incremental, stale)).toMatchObject({ type: "rejected", recovery: "retry-full" })
// A full send has no continuation to blame, so the same error stays a provider failure.
const full = yield* second.create(undefined)
expect(yield* second.observe(full, stale)).toMatchObject({ type: "provider-failure" })
// A classified failure keeps its runner-owned recovery instead of resending the whole context.
const overflow = ProviderShared.encodeJson({
type: "error",
error: { type: "invalid_request_error", code: "context_length_exceeded", message: "Too long" },
})
expect(yield* second.observe(yield* second.create(saved), overflow)).toMatchObject({
type: "provider-failure",
error: { reason: { _tag: "InvalidRequest", classification: "context-overflow" } },
})
}),
)
it.effect("builds WebSocket and HTTP fallback from the same final request", () =>
Effect.gen(function* () {
const attempts = yield* Ref.make(0)
@@ -2050,6 +2118,7 @@ describe("OpenAI Responses route", () => {
type: "message",
id: "msg_refusal",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "I can't help with that." }],
phase: "final_answer",
},
@@ -2132,6 +2201,7 @@ describe("OpenAI Responses route", () => {
type: "message",
id: "msg_commentary",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "Checking." }],
phase: "commentary",
},
@@ -2139,6 +2209,7 @@ describe("OpenAI Responses route", () => {
type: "message",
id: "msg_final",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "Finished." }],
phase: "final_answer",
},
@@ -2146,6 +2217,7 @@ describe("OpenAI Responses route", () => {
type: "message",
id: "msg_null",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "Unclassified." }],
phase: null,
},
@@ -3276,14 +3348,19 @@ describe("OpenAI Responses route", () => {
)
expect(prepared.body.input).toEqual([
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "Before." }] },
{
type: "message",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "Before." }],
},
{
type: "reasoning",
id: "rs_1",
encrypted_content: "encrypted-state",
summary: [{ type: "summary_text", text: "Checked order." }],
},
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "After." }] },
{ type: "message", role: "assistant", status: "completed", content: [{ type: "output_text", text: "After." }] },
])
}),
)
@@ -3547,12 +3624,14 @@ describe("OpenAI Responses route", () => {
type: "message",
id: "history_1",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "Hello" }],
},
{
type: "message",
id: `message_${"a".repeat(64)}`,
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "World" }],
},
{
@@ -0,0 +1,103 @@
import { DialogProvider } from "@opencode/ui/context/dialog"
import { Browser } from "@opencode/plugin-browser/rpc"
import { For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { render } from "solid-js/web"
import { LanguageProvider, UiI18nBridge } from "../src/runtime/i18n/language"
import type { BrowserPaneLayout, BrowserPaneRegistration } from "../src/runtime/platform/browser-pane"
import type { createSessionBrowser } from "../src/session/browser/model"
import { SessionBrowserPane } from "../src/session/browser/pane"
export function mountBrowserPane() {
const host = document.createElement("main")
host.dataset.testid = "browser-pane-fixture"
host.style.cssText = "position:fixed;inset:0;z-index:1000;background:#181818;color:#eee;padding:24px"
document.body.appendChild(host)
function Fixture() {
const [store, setStore] = createStore({
session: "Alpha",
mounted: true,
visible: true,
layouts: {} as Record<string, BrowserPaneLayout | undefined>,
})
const tabs = ["Alpha", "Beta"].map((name) => ({
id: Browser.TabID.make(`tab_${name === "Alpha" ? "11111111" : "22222222"}-1111-1111-1111-111111111111`),
title: name,
url: `https://${name.toLowerCase()}.example/`,
loading: false,
canGoBack: false,
canGoForward: false,
generation: 0,
}))
// Record the native boundary per registration: hiding Beta cannot hide Alpha's view.
const registrations = new Map<string, BrowserPaneRegistration>(
tabs.map((tab) => [
tab.title,
{
setLayout: (layout) => setStore("layouts", tab.title, layout),
command: async () => undefined,
close: () => undefined,
},
]),
)
const browser: ReturnType<typeof createSessionBrowser> = {
available: () => true,
attached: () => !!registrations.get(store.session),
opened: () => !!registrations.get(store.session),
state: () => ({ tabs: tabs.filter((tab) => tab.title === store.session), focusedTabID: null }),
tabs: () => tabs.filter((tab) => tab.title === store.session),
active: () => tabs.find((tab) => tab.title === store.session) ?? tabs[0],
registration: () => registrations.get(store.session),
error: () => undefined,
suspended: () => false,
close: () => undefined,
open: () => undefined,
command: () => undefined,
}
return (
<>
<h1 style={{ "font-size": "24px", "margin-bottom": "16px" }}>Browser pane lifecycle</h1>
<p>Selected session: {store.session}</p>
<nav style={{ display: "flex", gap: "20px", margin: "16px 0" }}>
<For each={["Alpha", "Beta", "Empty"]}>
{(name) => <button onClick={() => setStore({ session: name, mounted: name !== "Empty" })}>{name}</button>}
</For>
<button onClick={() => setStore("mounted", false)}>Unmount pane</button>
<button onClick={() => setStore("visible", (visible) => !visible)}>Toggle Review tab</button>
</nav>
<div style={{ width: "640px", height: "360px", border: "1px solid #555" }}>
<Show when={store.mounted}>
<SessionBrowserPane browser={browser} visible={store.visible} />
</Show>
</div>
<h2 style={{ "font-size": "18px", margin: "20px 0 12px" }}>Native layout recorder</h2>
<p>The desktop boundary keeps each session's page visible until its registration is hidden.</p>
<For each={tabs}>
{(tab) => (
<div
data-testid={`native-${tab.title}`}
data-visible={!!store.layouts[tab.title]?.visible}
style={{ padding: "12px", margin: "8px 0", border: "1px solid #555" }}
>
{tab.title}: {store.layouts[tab.title]?.visible ? "visible" : "hidden"}
</div>
)}
</For>
</>
)
}
return render(
() => (
<LanguageProvider locale="en">
<UiI18nBridge>
<DialogProvider>
<Fixture />
</DialogProvider>
</UiI18nBridge>
</LanguageProvider>
),
host,
)
}
@@ -0,0 +1,44 @@
import { fileURLToPath } from "node:url"
import { expect, story } from "../../storybook/playwright/story"
const fixture = `/@fs/${fileURLToPath(new URL("./browser-pane.fixture.tsx", import.meta.url)).replaceAll("\\", "/")}`
story.beforeEach(async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--mixed-attachments")
await expect(component.getByRole("textbox", { name: "Prompt", exact: true })).toBeVisible()
await page.evaluate(async (fixture) => {
const { mountBrowserPane } = await import(fixture)
mountBrowserPane()
}, fixture)
await expect(page.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
})
story("hides the previous registration when the mounted pane switches sessions", async ({ page }, testInfo) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Beta", exact: true }).click()
await expect(root.getByTestId("native-Beta")).toHaveAttribute("data-visible", "true")
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
await page.screenshot({ path: testInfo.outputPath("session-switch.png") })
await root.getByRole("button", { name: "Alpha", exact: true }).click()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await expect(root.getByTestId("native-Beta")).toHaveAttribute("data-visible", "false")
})
story("hides the outgoing browser when the destination has no browser pane", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Empty", exact: true }).click()
await expect(root.locator("#browser-panel")).toHaveCount(0)
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
await root.getByRole("button", { name: "Alpha", exact: true }).click()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
})
story("hides and restores the same registration for Review tabs and unmount", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Toggle Review tab", exact: true }).click()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
await root.getByRole("button", { name: "Toggle Review tab", exact: true }).click()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await root.getByRole("button", { name: "Unmount pane", exact: true }).click()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
})
@@ -0,0 +1,27 @@
# Session-export load benchmark
Replay an exported session against a production app build. The two cases compare the default Compact preset with every category ungrouped and details still collapsed.
From `packages/app` in PowerShell:
```powershell
$env:PLAYWRIGHT_BUILD = '1'
$env:PLAYWRIGHT_BASE_URL = 'http://127.0.0.1:4398' # Existing production preview
$env:LAGGY_SESSION_FILE = 'C:\path\session.json'
$env:LAGGY_SESSION_OUTPUT = 'C:\tmp\opencode\session-load'
$env:LAGGY_SESSION_HISTORY = 'paged' # Or 'full' to supply all exported history
bun x playwright test --config e2e/performance/playwright.config.ts timeline/laggy-session-benchmark.spec.ts --repeat-each=20 --workers=1 --retries=0
bun e2e/performance/timeline/laggy-session-report.ts $env:LAGGY_SESSION_OUTPUT
```
Each test uses a fresh browser context and measures one cold load, switches back to the source session, then measures one warm load. Repetitions therefore interleave `cold → warm` pairs rather than collecting separate cold and warm batches. There are no discarded warm-up switches. The warm member of every pair must issue zero message requests. Each pair is saved in a separate JSON file; compare paired differences as well as the cold and warm distributions when system load varies.
The report writes `summary.json` and prints the median, p95, maximum, and median paired cold-minus-warm difference. It rejects incomplete or cold-only records instead of mixing them into paired results.
`--repeat-each=20` collects 20 pairs per grouping mode. `LAGGY_SESSION_COLD_ONLY=1` remains available for focused cold profiling. Screenshots are taken after a pair finishes, not between its measurements.
The app shell, source session, model control, and fonts are ready before the timed action. These measurements cover session entry, not application startup. `firstCorrectObservedMs` begins at mousedown and ends when the destination is visible at its expected bottom position, including Compact's automatic history fill. `stableObservedMs` includes three-observation confirmation and must not be treated as additional rendering time.
Set `OPENCODE_PERFORMANCE_TRACE_DIR` for Chrome traces. `LAGGY_TRACE_ITERATION=0` traces the cold load; the default (`1`) traces the warm member of the pair. Profile separately from timing runs.
`LAGGY_HTTP=1` disables route interception for an external HTTP replay server containing the same export and source fixture. Keep direct HTTP and Playwright-routed cold series separate: routing adds transport overhead. Raw samples, mode settings, viewport, browser version, and screenshots are retained in the output directory. The export itself is not copied into the repository.
@@ -0,0 +1,227 @@
import { readFileSync, mkdirSync, writeFileSync } from "node:fs"
import type { SessionMessageInfo } from "@opencode/client/promise"
import { base64Encode } from "@opencode/util/encode"
import { timelineCategories, timelinePresets } from "@opencode/session-ui/timeline/detail"
import { mockOpenCodeServer } from "../../utils/mock-server"
import { expectSessionTitle } from "../../utils/waits"
import { benchmark, expect } from "../benchmark"
import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
import { stressSessionHref } from "./timeline-test-helpers"
import { startChromeTrace } from "../chrome-trace"
const file = process.env.LAGGY_SESSION_FILE
const session = file
? (JSON.parse(readFileSync(file, "utf8")) as {
info: {
id: string
projectID: string
title: string
model?: { id: string; providerID: string }
location: { directory: string }
time: { created: number; updated: number }
}
messages: SessionMessageInfo[]
})
: undefined
const sourceID = "ses_laggy_benchmark_source"
const sourceMessageID = "msg_laggy_benchmark_source"
const history = process.env.LAGGY_SESSION_HISTORY ?? "full"
const viewport = { width: 1440, height: 900 }
benchmark.use({ viewport, video: "off", trace: "off", serviceWorkers: "block", traceScope: "interaction" })
for (const mode of ["compact", "ungrouped"] as const) {
benchmark(`laggy session: ${mode}`, async ({ page, report }, testInfo) => {
benchmark.skip(!session, "Set LAGGY_SESSION_FILE to a session export")
if (!session) return
const output = process.env.LAGGY_SESSION_OUTPUT ?? testInfo.outputPath("session-load")
const model = session.info.model ?? { id: "benchmark-model", providerID: "benchmark" }
const lastID = session.messages.findLast((message) => message.type === "user")!.id
const lastText = session.messages.findLast(
(message) =>
message.type === "assistant" && message.content.some((part) => part.type === "text" && part.text.trim()),
)!
benchmark.setTimeout(Number(process.env.LAGGY_SESSION_TIMEOUT ?? 180_000))
const requests: string[] = []
const errors: string[] = []
page.on("pageerror", (error) => errors.push(error.message))
if (process.env.LAGGY_HTTP === "1")
page.on("request", (request) => {
const match = new URL(request.url()).pathname.match(/^\/api\/session\/([^/]+)\/message$/)
if (request.method() === "GET" && match) requests.push(decodeURIComponent(match[1]))
})
const detail = Object.fromEntries(
timelineCategories.map((category) => [
category,
{
...timelinePresets[2].value[category],
placement: mode === "compact" ? "grouped" : "separate",
},
]),
)
const directory = session.info.location.directory
if (process.env.LAGGY_HTTP !== "1")
await mockOpenCodeServer(page, {
directory,
project: {
id: session.info.projectID,
worktree: directory,
vcs: "git",
name: "session-benchmark",
time: session.info.time,
sandboxes: [],
},
provider: {
all: [
{
id: model.providerID,
name: model.providerID,
models: { [model.id]: { id: model.id, name: model.id, limit: { context: 1_000_000 } } },
},
],
connected: [model.providerID],
default: { providerID: model.providerID, modelID: model.id },
},
sessions: [session.info, { ...session.info, id: sourceID, title: "Benchmark source" }],
pageMessages: (id, limit, before) => {
if (id !== session.info.id)
return {
items: [
{
id: sourceMessageID,
type: "user",
text: "Benchmark source",
time: { created: session.info.time.created },
},
],
}
if (history === "full") return { items: session.messages }
const end = before ? session.messages.findIndex((message) => message.id === before) : session.messages.length
const start = Math.max(0, end - limit)
return {
items: session.messages.slice(start, end),
cursor: start > 0 ? session.messages[start].id : undefined,
}
},
onMessages: (request) => {
if (request.phase === "start") requests.push(request.sessionID)
},
})
await page.addInitScript(
({ detail, directory, server, sessionIDs, dirBase64 }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { timelineDetail: detail } }))
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify(sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64, sessionId }))),
)
},
{
detail,
directory,
server: process.env.PLAYWRIGHT_BASE_URL!,
sessionIDs: [sourceID, session.info.id],
dirBase64: base64Encode(directory),
},
)
await page.goto(stressSessionHref(sourceID))
await expectSessionTitle(page, "Benchmark source")
await expect(page.locator('[data-slot="user-message-text"]')).toHaveText("Benchmark source")
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
await expect(page.getByRole("button", { name: model.id, exact: true })).toBeVisible()
await page.evaluate(() => document.fonts.ready.then(() => undefined))
expect(requests).toEqual([sourceID])
const startedAt = new Date().toISOString()
const samples = []
const phases = process.env.LAGGY_SESSION_COLD_ONLY === "1" ? (["cold"] as const) : (["cold", "warm"] as const)
for (const [iteration, phase] of phases.entries()) {
const before = requests.length
const stopTrace =
iteration === Number(process.env.LAGGY_TRACE_ITERATION ?? 1)
? await startChromeTrace(page, `laggy-${history}-${mode}`)
: undefined
const result = await measureSessionSwitch(page, {
destinationIDs: session.messages.map((message) => message.id),
sourceIDs: [sourceMessageID],
lastID,
requiredPartID: history === "paged" && mode === "compact" ? `${lastText.id}:text:0` : undefined,
requireBottomAnchor: true,
href: stressSessionHref(session.info.id),
switch: async () => {
await page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(session.info.id)}"]`).click()
},
})
await stopTrace?.()
await expectSessionTitle(page, session.info.title)
if (history === "full" || mode === "ungrouped") await waitForStableTimeline(page, lastID)
await expect(
page.locator('[data-timeline-key] [data-component="markdown"]:not([data-markdown-ready])'),
).toHaveCount(0)
expect(result.firstCorrectObservedMs).not.toBeNull()
expect(result.stableObservedMs).not.toBeNull()
if (phase === "warm") expect(requests.length - before).toBe(0)
samples.push({
iteration,
phase,
messageRequests: requests.length - before,
messageResources: await page.evaluate((sessionID) => {
const start = performance.getEntriesByName("session-switch:start").at(-1)!.startTime
return (performance.getEntriesByType("resource") as PerformanceResourceTiming[])
.filter(
(entry) =>
entry.startTime >= start && new URL(entry.name).pathname === `/api/session/${sessionID}/message`,
)
.map((entry) => ({
limit: Number(new URL(entry.name).searchParams.get("limit")),
startMs: entry.startTime - start,
durationMs: entry.duration,
transferBytes: entry.transferSize,
}))
}, session.info.id),
...result,
})
if (iteration === phases.length - 1) {
mkdirSync(output, { recursive: true })
if (testInfo.repeatEachIndex === 0) await page.screenshot({ path: `${output}/${mode}.png` })
break
}
await page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(sourceID)}"]`).click()
await expectSessionTitle(page, "Benchmark source")
await expect(page.locator('[data-slot="user-message-text"]')).toHaveText("Benchmark source")
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
}
expect(errors).toEqual([])
const result = {
pair: testInfo.repeatEachIndex,
startedAt,
mode,
history,
file,
messages: session.messages.length,
viewport,
browser: page.context().browser()!.version(),
detail,
samples,
}
writeFileSync(`${output}/${mode}-${testInfo.repeatEachIndex}.json`, JSON.stringify(result, null, 2))
report(
{ samples },
{
mode,
sampling: "cold/warm pair in one browser context",
pair: testInfo.repeatEachIndex,
messages: session.messages.length,
viewport,
data: history === "full" ? "full exported history" : "paginated exported history",
transport: process.env.LAGGY_HTTP === "1" ? "http" : "playwright-route",
inputEvent: "mousedown",
},
)
})
}
@@ -0,0 +1,63 @@
export {}
type Pair = {
mode: "compact" | "ungrouped"
samples: { phase: string; firstCorrectObservedMs: number | null; messageRequests: number }[]
}
const directory = Bun.argv[2]
if (!directory) throw new Error("Pass the directory containing session-load pairs")
const pairs = await Promise.all(
[...new Bun.Glob("{compact,ungrouped}-*.json").scanSync(directory)].map(async (file) => {
const pair = (await Bun.file(`${directory}/${file}`).json()) as Pair
const cold = pair.samples.find((sample) => sample.phase === "cold")
const warm = pair.samples.find((sample) => sample.phase === "warm")
if (cold?.firstCorrectObservedMs == null || warm?.firstCorrectObservedMs == null)
throw new Error(`Expected a completed cold/warm pair in ${file}`)
return {
mode: pair.mode,
cold: cold.firstCorrectObservedMs,
warm: warm.firstCorrectObservedMs,
requests: warm.messageRequests,
}
}),
)
if (!pairs.length) throw new Error(`No session-load pairs found in ${directory}`)
const result = ["compact", "ungrouped"].flatMap((mode) => {
const selected = pairs.filter((pair) => pair.mode === mode)
if (!selected.length) return []
return [
{
mode,
cold: { ...stats(selected.map((pair) => pair.cold)), over50ms: selected.filter((pair) => pair.cold > 50).length },
warm: { ...stats(selected.map((pair) => pair.warm)), over50ms: selected.filter((pair) => pair.warm > 50).length },
pairedColdMinusWarm: stats(selected.map((pair) => pair.cold - pair.warm)),
messageRequestsDuringWarm: selected.reduce((total, pair) => total + pair.requests, 0),
},
]
})
await Bun.write(`${directory}/summary.json`, JSON.stringify(result, null, 2))
console.table(
result.map((row) => ({
mode: row.mode,
pairs: row.cold.n,
coldMedianMs: Math.round(row.cold.median * 10) / 10,
coldP95Ms: Math.round(row.cold.p95 * 10) / 10,
warmMedianMs: Math.round(row.warm.median * 10) / 10,
warmP95Ms: Math.round(row.warm.p95 * 10) / 10,
warmMaxMs: Math.round(row.warm.max * 10) / 10,
pairedDifferenceMs: Math.round(row.pairedColdMinusWarm.median * 10) / 10,
})),
)
function stats(values: number[]) {
const sorted = values.toSorted((left, right) => left - right)
return {
n: sorted.length,
median: (sorted[Math.floor((sorted.length - 1) / 2)] + sorted[Math.floor(sorted.length / 2)]) / 2,
p95: sorted[Math.ceil(sorted.length * 0.95) - 1],
min: sorted[0],
max: sorted.at(-1)!,
}
}
@@ -16,6 +16,11 @@ test("status drawer dismisses and reopens after button, backdrop, Escape, and dr
await more.click()
await page.getByRole("menuitem", { name: "Status", exact: true }).click()
await expect(drawer.getByRole("tab", { name: "MCP", exact: true })).toBeVisible()
// Corvu starts opening after paint; the transition flag is also absent
// before that callback. Wait for the open position before dismissing.
await expect
.poll(() => drawer.evaluate((element) => new DOMMatrixReadOnly(getComputedStyle(element).transform).m42))
.toBe(0)
await expect(drawer).not.toHaveAttribute("data-transitioning")
if (dismissal === "button") await drawer.getByRole("button", { name: "Close", exact: true }).click()
if (dismissal === "backdrop") await overlay.click({ position: { x: 10, y: 10 } })
@@ -22,6 +22,10 @@ test("selects a base branch for a new workspace", async ({ page }) => {
pageMessages: () => ({ items: [] }),
vcsBranches: ["feature/api", "main", "origin/release"],
})
await page.route("**/api/vcs/branches?*", (route) => {
if (new URL(route.request().url()).searchParams.get("search") !== "feature") return route.fallback()
return route.fulfill({ json: { location: { directory }, data: ["feature/api"] } })
})
await page.addInitScript(
({ directory, draftID, server }) => {
localStorage.setItem(
@@ -44,10 +48,28 @@ test("selects a base branch for a new workspace", async ({ page }) => {
await page.getByRole("button", { name: "Local", exact: true }).click()
await page.getByRole("menuitem", { name: "New worktree", exact: true }).click()
await page.getByRole("button", { name: "from main", exact: true }).click()
const search = page.getByRole("textbox", { name: "Search branches", exact: true })
await expect(search).toBeFocused()
await page.keyboard.type("feature")
await expect(search).toHaveValue("feature")
await expect(page.getByRole("menuitemradio")).toHaveText(["feature/api"])
await expect(search).toBeFocused()
await page.getByRole("menuitemradio", { name: "feature/api", exact: true }).click()
const selected = page.getByRole("button", { name: "from feature/api", exact: true })
await expect(selected).toBeVisible()
await selected.click()
await expect(search).toBeFocused()
await expect(search).toHaveValue("")
await expect(page.getByRole("menuitemradio", { name: "feature/api", exact: true })).toBeChecked()
await page.keyboard.press("Escape")
await expect(selected).toBeFocused()
await page.keyboard.press("Enter")
await expect(search).toBeFocused()
await page.keyboard.type("feature")
await expect(search).toHaveValue("feature")
await expect(page.getByRole("menuitemradio")).toHaveText(["feature/api"])
await page.getByRole("button", { name: "Clear", exact: true }).click()
await expect(search).toHaveValue("")
await expect(page.getByRole("menuitemradio")).toHaveText(["feature/api", "main", "origin/release"])
})
@@ -238,16 +238,9 @@ async function expectHeaderClearOfToggle(page: Page, toggle: Locator, progress:
})
const chatBounds = chat.getBoundingClientRect()
const panelBounds = document.querySelector("#review-panel")!.getBoundingClientRect()
const summaryBounds = document
.querySelector('[data-session-title] button[aria-label="Session details"]')!
.getBoundingClientRect()
return {
row: row.getBoundingClientRect().width,
panelWidth: panelBounds.width,
timelineControlInset:
getComputedStyle(row).direction === "rtl"
? summaryBounds.left - chatBounds.left
: chatBounds.right - summaryBounds.right,
gap:
getComputedStyle(row).direction === "rtl"
? chatBounds.left - panelBounds.right
@@ -257,8 +250,6 @@ async function expectHeaderClearOfToggle(page: Page, toggle: Locator, progress:
}
}, progress)
expect(geometry.gap).toBeCloseTo(8, 1)
// Reserve the fixed toggle's 28px width, the 8px control gap, and the 12px header inset.
expect(geometry.timelineControlInset).toBeCloseTo(48, 1)
if (geometry.panelWidth > 0) expect(Math.abs(geometry.row - geometry.panels)).toBeLessThanOrEqual(1)
if (progress === 0.25) {
expect(geometry.contentOpacity).toBeGreaterThan(0)
@@ -0,0 +1,315 @@
import { expect, test } from "@playwright/test"
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode/client/promise"
import { timelinePresets } from "@opencode/session-ui/timeline/detail"
import { mockOpenCodeServer } from "../utils/mock-server"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { installStressSessionTabs, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
import { waitForStableTimeline } from "../performance/timeline/session-tab-switch-probe"
test.use({ viewport: { width: 1440, height: 900 }, serviceWorkers: "block" })
test("recovers from a failed cold history load when another session is selected", async ({ page }) => {
await mockOpenCodeServer(page, {
...fixture,
pageMessages: (id) => ({
items: [{ id: `msg_${id}`, type: "user", text: `History for ${id}`, time: { created: 1 } }],
}),
})
await page.route(`**/api/session/${fixture.targetID}/message?*`, (route) =>
route.fulfill({ status: 500, json: { message: "History unavailable" } }),
)
await installStressSessionTabs(page)
await page.goto(stressSessionHref(fixture.sourceID))
await expect(page.getByText(`History for ${fixture.sourceID}`, { exact: true })).toBeVisible()
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
await expect(page.getByRole("heading", { name: "Something went wrong", exact: true })).toBeVisible()
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.sourceID)}"]`).click()
await expect(page.getByText(`History for ${fixture.sourceID}`, { exact: true })).toBeVisible()
await expect(page.getByRole("heading", { name: "Something went wrong", exact: true })).toHaveCount(0)
})
test("focuses Find in the selected cached timeline", async ({ page }) => {
await mockOpenCodeServer(page, {
...fixture,
pageMessages: (id) => ({
items: [{ id: `msg_${id}`, type: "user", text: `History for ${id}`, time: { created: 1 } }],
}),
})
await installStressSessionTabs(page)
await page.goto(stressSessionHref(fixture.sourceID))
await expect(page.getByText(`History for ${fixture.sourceID}`, { exact: true })).toBeVisible()
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
await expect(page.getByText(`History for ${fixture.targetID}`, { exact: true })).toBeVisible()
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.sourceID)}"]`).click()
await expect(page.getByText(`History for ${fixture.sourceID}`, { exact: true })).toBeVisible()
await page.keyboard.press("ControlOrMeta+f")
const search = page.locator('[data-component="timeline-search-bar"] input')
await expect(search).toBeFocused()
await page.keyboard.type("History")
await expect(search).toHaveValue("History")
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
await expect(page.getByText(`History for ${fixture.targetID}`, { exact: true })).toBeVisible()
await page.keyboard.press("ControlOrMeta+f")
await expect(search).toBeFocused()
await search.press("Escape")
await expect(search).toHaveCount(0)
})
test("disposes the old workspace's shell while destination history is loading", async ({ page }) => {
const destination = "C:/OpenCode/OtherProject"
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
const reads: string[] = []
const output = { text: "Initial shell output\n" }
await mockOpenCodeServer(page, {
...fixture,
sessions: fixture.sessions.map((session) =>
session.id === fixture.targetID ? { ...session, directory: destination } : session,
),
pageMessages: (id) => ({
items:
id === fixture.sourceID
? ([
{ id: "msg_workspace_source", type: "user", text: "Follow the shell", time: { created: 1 } },
{
id: "msg_workspace_shell",
type: "assistant",
agent: "build",
model: { id: "claude-opus-4-6", providerID: "opencode" },
time: { created: 2 },
content: [
{
type: "tool",
id: "call_workspace_shell",
name: "shell",
time: { created: 2 },
state: {
status: "running",
input: { command: "run checks" },
metadata: { shellID: "sh_workspace_source" },
},
},
],
},
] satisfies SessionMessageInfo[])
: [],
}),
beforeMessagesResponse: async ({ sessionID }) => {
if (sessionID !== fixture.targetID) return
requested.resolve()
await release.promise
},
})
await page.route("**/api/shell/sh_workspace_source/output?*", (route) => {
const url = new URL(route.request().url())
const directory = url.searchParams.get("location[directory]")!
reads.push(directory)
if (directory !== fixture.directory)
return route.fulfill({ status: 404, json: { _tag: "ShellNotFoundError", id: "sh_workspace_source" } })
return route.fulfill({
json: {
location: { directory },
data: {
output: output.text.slice(Number(url.searchParams.get("cursor") ?? 0)),
cursor: output.text.length,
size: output.text.length,
truncated: false,
},
},
})
})
await installStressSessionTabs(page)
await page.addInitScript(
(detail) =>
localStorage.setItem(
"settings.v3",
JSON.stringify({
general: {
timelineDetail: { ...detail, shell: { placement: "separate", details: "expanded" } },
},
}),
),
timelinePresets[2].value,
)
await page.goto(stressSessionHref(fixture.sourceID))
const shell = page.locator('[data-timeline-part-id="call_workspace_shell"]')
await expect(shell.locator('[data-slot="bash-result"]')).toContainText("Initial shell output")
const original = await page.locator("[data-timeline-virtual-content]").elementHandle()
try {
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
await requested.promise
await expect(page.locator("[data-session-title]")).toHaveText(fixture.expected.targetTitle)
output.text += "Output after returning\n"
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.sourceID)}"]`).click()
await expect(shell.locator('[data-slot="bash-result"]')).toContainText("Output after returning")
expect(await original!.evaluate((element) => element.isConnected)).toBe(false)
expect(reads.length).toBeGreaterThan(1)
expect(reads.every((directory) => directory === fixture.directory)).toBe(true)
} finally {
release.resolve()
}
})
test("loads the transcript code font before opening rich history", async ({ page }) => {
const font = page.waitForResponse((response) => /IBMPlexMono-Text[^/]*\.woff2/.test(response.url()))
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
provider: fixture.provider,
sessions: fixture.sessions,
pageMessages: () => ({
items: [{ id: "msg_font_source", type: "user", text: "A transcript with no code", time: { created: 1 } }],
}),
})
await installStressSessionTabs(page)
await page.goto(stressSessionHref(fixture.sourceID))
await expect(page.getByText("A transcript with no code", { exact: true })).toBeVisible()
expect((await font).ok()).toBe(true)
await expect.poll(() => page.evaluate(() => document.fonts.check('440 13px "IBM Plex Mono"'))).toBe(true)
})
test("waits for the requested session's history before constructing its cold timeline", async ({ page }) => {
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
provider: fixture.provider,
sessions: fixture.sessions,
pageMessages: (id) => ({ items: fixture.messages[id] ?? [] }),
beforeMessagesResponse: async ({ sessionID }) => {
if (sessionID !== fixture.targetID) return
requested.resolve()
await release.promise
},
})
await installStressSessionTabs(page)
await page.goto(stressSessionHref(fixture.sourceID))
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
try {
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
await requested.promise
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCount(0)
release.resolve()
await waitForStableTimeline(page, fixture.expected.targetMessageIDs.at(-1)!)
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCount(1)
} finally {
release.resolve()
}
})
for (const grouped of [true, false]) {
test(`restores a ${grouped ? "grouped" : "separate"} timeline after inactive updates and a resize`, async ({
page,
}) => {
const events: OpenCodeEvent[] = []
const messages: Record<string, SessionMessageInfo[]> = Object.fromEntries(
[fixture.sourceID, fixture.targetID].map((id) => [
id,
[
{ 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: "tool",
id: `tool_${id}`,
name: "shell",
time: { created: 2, completed: 3 },
state: {
status: "completed",
input: { command: `echo ${id}` },
metadata: {},
content: [{ type: "text", text: `Output for ${id}` }],
},
},
{ type: "text", text: `Answer for ${id}` },
],
},
] satisfies SessionMessageInfo[],
]),
)
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
provider: fixture.provider,
sessions: fixture.sessions,
pageMessages: (id) => ({ items: messages[id] ?? [] }),
events: () => events.splice(0),
})
await installStressSessionTabs(page)
await page.addInitScript(
({ grouped, detail }) => {
localStorage.setItem(
"settings.v3",
JSON.stringify({
general: {
timelineDetail: {
...detail,
shell: { placement: grouped ? "grouped" : "separate", details: "collapsed" },
},
},
}),
)
},
{ grouped, detail: timelinePresets[2].value },
)
await page.goto(stressSessionHref(fixture.sourceID))
await expect(page.getByText(`Answer for ${fixture.sourceID}`, { exact: true })).toBeVisible()
if (grouped)
await page
.locator(
'[data-component="collapsed-tool-group"] > [data-component="collapsible"] > [data-slot="collapsible-trigger"]',
)
.click()
const shell = page.locator(`[data-timeline-part-id="tool_${fixture.sourceID}"]`)
const trigger = shell.locator('[data-slot="collapsible-trigger"]')
await trigger.click()
await expect(shell.locator('[data-slot="bash-result"]')).toHaveText(`Output for ${fixture.sourceID}`)
const original = await page.locator("[data-timeline-virtual-content]").elementHandle()
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
await expect(page.getByText(`Answer for ${fixture.targetID}`, { exact: true })).toBeVisible()
await expect(shell).toHaveCount(0)
expect(await original!.evaluate((element) => element.isConnected)).toBe(false)
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCount(1)
events.push({
id: "evt_cached_text",
created: 4,
type: "session.text.ended",
location: { directory: fixture.directory },
durable: { aggregateID: fixture.sourceID, seq: 0, version: 1 },
data: {
sessionID: fixture.sourceID,
assistantMessageID: `msg_assistant_${fixture.sourceID}`,
ordinal: 0,
text: "Updated while inactive",
},
})
await page.setViewportSize({ width: 900, height: 650 })
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.sourceID)}"]`).click()
await expect(page.getByText("Updated while inactive", { exact: true })).toBeVisible()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await expect(shell.locator('[data-slot="bash-result"]')).toHaveText(`Output for ${fixture.sourceID}`)
expect(await original!.evaluate((element) => element.isConnected)).toBe(true)
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCount(1)
await expect
.poll(() =>
page
.locator("[data-timeline-key]")
.evaluateAll((rows) =>
rows.every(
(row) =>
(row.firstElementChild?.getBoundingClientRect().height ?? 0) <= row.getBoundingClientRect().height + 1,
),
),
)
.toBe(true)
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
})
}
@@ -17,7 +17,7 @@ import { mockOpenCodeServer } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport"
import { expectSessionTitle } from "../utils/waits"
const messagePageSize = 20
const messagePageSize = 40
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const messages = Array.from({ length: messagePageSize / 2 + 1 }, (_, index) => {
const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user`
@@ -188,7 +188,7 @@ for (const scenario of scenarios) {
await waitForProbeSamples(page, beforeHistory)
expect(pages).toEqual([
{ before: undefined, limit: messagePageSize },
{ before: messages.at(-messagePageSize)!.id, limit: messagePageSize },
{ before: messages.at(-messagePageSize)!.id, limit: 20 },
])
expect(roots).toEqual([])
@@ -9,11 +9,11 @@ test.use({ viewport: { width: 1440, height: 900 }, serviceWorkers: "block" })
for (const window of ["assistant-only", "mixed"] as const) {
test(`renders the ${window} latest page before parent hydration and preserves it afterward`, async ({ page }) => {
const session = { ...fixture.sessions[0]!, id: `ses_hydration_${window}` }
// Both 20-message pages begin with an assistant; only page three supplies its parent.
const messages = Array.from({ length: 41 }, (_, index): SessionMessageInfo => {
// Compact's initial 40 and the next 20 begin with an assistant; page three supplies its parent.
const messages = Array.from({ length: 61 }, (_, index): SessionMessageInfo => {
const id = `msg_hydration_${index}`
const time = { created: 1700000000000 + index * 1_000 }
if (index === 0 || (window === "mixed" && index === 39))
if (index === 0 || (window === "mixed" && index === 59))
return { id, type: "user", time, text: `Prompt ${index}` }
return {
id,
@@ -21,7 +21,7 @@ for (const window of ["assistant-only", "mixed"] as const) {
time: { ...time, completed: time.created + 500 },
model: { id: "claude-opus-4-6", providerID: "opencode" },
agent: "build",
content: [{ type: "text", text: index === 40 ? "## Hydrated tail\n\n**Ready.**" : `Answer ${index}` }],
content: [{ type: "text", text: index === 60 ? "## Hydrated tail\n\n**Ready.**" : `Answer ${index}` }],
}
})
const gates = [21, 1].map((index) => ({
@@ -43,18 +43,18 @@ for (const window of ["assistant-only", "mixed"] as const) {
await gate.release.promise
},
pageMessages: (_, limit, before) => {
expect(limit).toBe(20)
expect(limit).toBe(before ? 20 : 40)
const end = before ? messages.findIndex((message) => message.id === before) : messages.length
const start = Math.max(0, end - limit)
return { items: messages.slice(start, end), cursor: start > 0 ? messages[start]!.id : undefined }
},
})
const tail = page.locator('[data-timeline-part-id="msg_hydration_40:text:0"]')
const tail = page.locator('[data-timeline-part-id="msg_hydration_60:text:0"]')
const markdown = tail.locator('[data-component="markdown"]')
const content = page.locator("[data-timeline-virtual-content]", { has: tail })
const viewport = page.locator(".scroll-view__viewport", { has: tail })
const orphan = page.locator('[data-timeline-row="AssistantPart"]', {
has: page.locator('[data-timeline-part-id="msg_hydration_38:text:0"]'),
has: page.locator('[data-timeline-part-id="msg_hydration_58:text:0"]'),
})
const expectReadyTail = async () => {
await expect(content).toHaveCSS("visibility", "visible")
@@ -75,7 +75,7 @@ for (const window of ["assistant-only", "mixed"] as const) {
await expect(orphan).toHaveAttribute("data-message-id", "msg_hydration_21")
if (window === "mixed")
await expect(
page.locator('[data-timeline-row="UserMessage"][data-message-id="msg_hydration_39"]'),
page.locator('[data-timeline-row="UserMessage"][data-message-id="msg_hydration_59"]'),
).toBeInViewport()
const original = await markdown.elementHandle()
@@ -7,7 +7,6 @@ import {
compactionFailed,
compactionStarted,
directory,
event,
session,
sessionID,
setupTimeline,
@@ -87,12 +86,13 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
expect(ownerWarnings).toEqual([])
})
test("renders a compaction summary while it streams and after completion", async ({ page }) => {
test("renders compaction progress, summary, and outcome in order", async ({ page }) => {
const timeline = await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, notices: { placement: "separate" } },
},
sessionMessages: [user, assistant(true)],
sessionStatus: { [sessionID]: { type: "busy" } },
})
await timeline.send(
@@ -104,7 +104,15 @@ test("renders a compaction summary while it streams and after completion", async
)
const compaction = page.locator('[data-component="session-compaction-message"]')
await expect(compaction.getByText("Session compacted", { exact: true })).toBeVisible()
await expect(compaction.getByText("Session compaction started", { exact: true })).toBeVisible()
await expect(compaction.getByRole("status").getByLabel("Compacting", { exact: true })).toBeVisible()
await expect(compaction.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
await expect(compaction.getByText("Session compacted", { exact: true })).toHaveCount(0)
await expect(page.getByRole("button", { name: "Stop", exact: true })).toBeVisible()
await expect(page.locator('[data-component="session-working"]')).toHaveCount(0)
await page.setViewportSize({ width: 480, height: 900 })
await expect(compaction.getByText("Session compaction started", { exact: true })).toBeInViewport()
await timeline.send(
compactionDelta({
@@ -114,6 +122,8 @@ test("renders a compaction summary while it streams and after completion", async
)
await expect(compaction.getByRole("heading", { name: "Checkpoint" })).toBeVisible()
await expect(compaction).toContainText("Streamed implementation details.")
await expect(compaction.getByRole("status").getByLabel("Compacting", { exact: true })).toBeVisible()
await expect(compaction.getByText("Session compacted", { exact: true })).toHaveCount(0)
await timeline.send(
compactionEnded({
@@ -125,6 +135,18 @@ test("renders a compaction summary while it streams and after completion", async
)
await expect(compaction).toContainText("Final implementation details.")
await expect(compaction).not.toContainText("Streamed implementation details.")
await expect(compaction.getByText("Session compaction started", { exact: true })).toBeVisible()
await expect(compaction.getByText("Session compacted", { exact: true })).toBeVisible()
await expect
.poll(async () => {
const summary = await compaction.locator('[data-component="text-part"]').boundingBox()
const completed = await compaction.getByText("Session compacted", { exact: true }).boundingBox()
return !!summary && !!completed && completed.y >= summary.y + summary.height
})
.toBe(true)
await expect(compaction.getByRole("status")).toHaveCount(0)
await expect(page.getByRole("button", { name: "Stop", exact: true })).toBeVisible()
await expect(page.locator('[data-component="session-working"]')).toBeVisible()
})
test("updates running compactions to failed and cancelled boundaries", async ({ page }) => {
@@ -146,7 +168,10 @@ test("updates running compactions to failed and cancelled boundaries", async ({
const compactions = page.locator('[data-component="session-compaction-message"]')
const failed = compactions.filter({ hasText: "The provider rejected the summary." })
await expect(failed.getByText("Session compacted", { exact: true })).toBeVisible()
await expect(failed.getByText("Session compaction started", { exact: true })).toBeVisible()
await expect(failed.getByText("Session compaction failed", { exact: true })).toBeVisible()
await expect(failed.getByText("Session compacted", { exact: true })).toHaveCount(0)
await expect(failed.getByRole("status")).toHaveCount(0)
await expect(failed.getByText("ProviderError: The provider rejected the summary.", { exact: true })).toBeVisible()
await expect(failed).not.toContainText("Partial summary that should be discarded.")
@@ -164,11 +189,48 @@ test("updates running compactions to failed and cancelled boundaries", async ({
await expect(compactions).toHaveCount(2)
const cancelled = compactions.filter({ hasNotText: "The provider rejected the summary." })
await expect(cancelled.getByText("Session compacted", { exact: true })).toBeVisible()
await expect(cancelled.getByText("Session compaction started", { exact: true })).toBeVisible()
await expect(cancelled.getByText("Session compaction cancelled", { exact: true })).toBeVisible()
await expect(cancelled.getByText("Session compacted", { exact: true })).toHaveCount(0)
await expect(cancelled.getByRole("status")).toHaveCount(0)
await expect(cancelled).not.toContainText("Cancellation detail should stay hidden.")
await expect(cancelled).not.toContainText("Summary before cancellation.")
})
test("shows an interrupted outcome when stopping automatic compaction", async ({ page }) => {
const timeline = await setupTimeline(page, {
sessionMessages: [user, assistant(true)],
sessionStatus: { [sessionID]: { type: "busy" } },
})
await timeline.send(compactionStarted({ sessionID, reason: "auto", recent: "" }))
await timeline.send(compactionDelta({ sessionID, text: "Partial automatic summary." }))
const compaction = page.locator('[data-component="session-compaction-message"]')
await expect(compaction.getByRole("status").getByLabel("Compacting", { exact: true })).toBeVisible()
await expect(compaction).toContainText("Partial automatic summary.")
const request = page.waitForRequest(
(request) =>
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/interrupt`,
)
await page.getByRole("button", { name: "Stop", exact: true }).click()
await request
await timeline.send(
compactionFailed({
sessionID,
reason: "auto",
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
}),
)
await expect(compaction.getByText("Session compaction started", { exact: true })).toBeVisible()
await expect(compaction.getByText("Session compaction interrupted", { exact: true })).toBeVisible()
await expect(compaction.getByText("Session compaction failed", { exact: true })).toHaveCount(0)
await expect(compaction.getByText("Session compacted", { exact: true })).toHaveCount(0)
await expect(compaction.getByRole("status")).toHaveCount(0)
await expect(compaction).not.toContainText("Partial automatic summary.")
await expect(compaction).not.toContainText("Compaction was interrupted")
})
test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
await setupTimeline(page, {
settings: {
@@ -0,0 +1,86 @@
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
for (const colorScheme of ["light", "dark"] as const) {
test.describe(colorScheme, () => {
test.use({ colorScheme, contextOptions: { reducedMotion: "reduce" } })
test("project card edges stay inside the settings scrollport", async ({ page }, info) => {
const projects = ["rebase", "dinocms", "opencode", "Playground"].map((name, index) => ({
id: `project-${index}`,
name,
canonical: `/projects/${name}`,
vcs: "git",
time: { created: 1, updated: 1 },
sandboxes: [],
}))
await mockOpenCodeServer(page, {
directory: "/projects/rebase",
project: projects[0],
sessions: [],
pageMessages: () => ({ items: [] }),
provider: { all: [], connected: [], default: {} },
})
await page.route("**/api/project", (route) =>
route.fulfill({ json: projects, headers: { "access-control-allow-origin": "*" } }),
)
await page.addInitScript((projects) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: projects.map((project) => ({ worktree: project.canonical, expanded: true })) },
}),
)
}, projects)
await page.goto("/")
await expect(page.getByRole("button", { name: "Settings", exact: true })).toBeEnabled()
await page.getByRole("button", { name: "Settings", exact: true }).click()
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
const panel = settings.getByRole("tabpanel")
await expect(panel.getByText("rebase", { exact: true })).toBeVisible()
await expect(panel.getByText("Playground", { exact: true })).toBeVisible()
await page.evaluate(() => document.fonts.ready)
for (const width of [1280, 1050, 960, 720, 600]) {
await page.setViewportSize({ width, height: 720 })
await page.mouse.move(0, 0)
await page.screenshot({ path: info.outputPath(`projects-${width}.png`), animations: "disabled" })
// Raised cards paint a half-pixel border outside their box. The scrollport
// must leave room for that border and the soft shadow on both sides.
await expect
.poll(() =>
panel.getByText("rebase", { exact: true }).evaluate((label) => {
const row = label.parentElement!.parentElement!
const bounds = row.getBoundingClientRect()
const clips = []
for (let parent = row.parentElement; parent; parent = parent.parentElement) {
if (getComputedStyle(parent).overflowX === "visible") continue
const clip = parent.getBoundingClientRect()
clips.push(bounds.left - clip.left, clip.right - bounds.right)
}
return Math.min(...clips)
}),
)
.toBeGreaterThanOrEqual(4)
await expect(panel).toHaveJSProperty("scrollWidth", await panel.evaluate((el) => el.clientWidth))
}
await page.setViewportSize({ width: 1280, height: 720 })
await panel.getByText("rebase", { exact: true }).hover()
await panel.getByText("rebase", { exact: true }).click()
const dialog = page.getByRole("dialog")
await expect(dialog.getByRole("textbox")).toHaveValue("rebase")
await expect(dialog.getByRole("textbox")).toBeFocused()
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
await expect(dialog).toBeHidden()
await expect(panel.getByText("rebase", { exact: true })).toBeVisible()
await page.setViewportSize({ width: 1280, height: 260 })
await panel.getByText("rebase", { exact: true }).hover()
await page.mouse.wheel(0, 400)
await expect(panel.getByText("Playground", { exact: true })).toBeInViewport({ ratio: 1 })
await expect(panel.getByRole("heading", { name: "Projects", exact: true })).toBeInViewport({ ratio: 1 })
})
})
}
+6
View File
@@ -80,6 +80,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const id = state.connections
let ended = false
let own: ReadableStreamDefaultController<Uint8Array> | undefined
let keepalive: ReturnType<typeof setInterval> | undefined
const stream = new ReadableStream<Uint8Array>({
start(controller) {
own = controller
@@ -89,11 +90,15 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
)
state.buffer.splice(0).forEach((item) => controller.enqueue(encoder.encode(item)))
// Match the real server's idle stream so long scenarios do not
// trigger the client's 45-second stall watchdog and reload history.
keepalive = setInterval(() => controller.enqueue(encoder.encode(": keepalive\n\n")), 15_000)
request.signal.addEventListener(
"abort",
() => {
if (ended) return
ended = true
clearInterval(keepalive)
if (state.controller === controller) state.controller = undefined
controller.error(request.signal.reason ?? new DOMException("The operation was aborted", "AbortError"))
},
@@ -103,6 +108,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
cancel() {
if (ended) return
ended = true
clearInterval(keepalive)
if (state.controller === own) state.controller = undefined
},
})
+10 -5
View File
@@ -122,9 +122,15 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
if (command) {
clearSubmission(input, submission)
void sendCommand(session, value, command, input.adapter.controls().model.selection.trackSessionCommit).catch(
(error) => failSubmission(input, session, "command", error, restore, value.id),
)
// Commands always steer: the server applies a command's configured
// agent and model immediately at admission, so queueing one would
// reconfigure the turn it is supposed to wait behind.
void sendCommand(
session,
{ ...value, delivery: "steer" },
command,
input.adapter.controls().model.selection.trackSessionCommit,
).catch((error) => failSubmission(input, session, "command", error, restore, value.id))
return
}
} finally {
@@ -316,8 +322,7 @@ async function sendCommand(
track?: ModelSelection["trackSessionCommit"],
) {
const request = await buildSubmissionRequest(session, value)
// Like queued prompts, queued commands must not apply the composer's selection to active work.
if (value.delivery === "steer") await applySelection(session, value.selection, track)
await applySelection(session, value.selection, track)
await session.api.command({
sessionID: session.id,
command: command.command,
@@ -242,14 +242,7 @@ export function PromptWorkspaceSelector(props: {
class="ms-1 min-w-0 max-w-[220px]"
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<Menu
placement="bottom"
gutter={4}
onOpenChange={(open) => {
onOpenChange(open)
if (open) requestAnimationFrame(() => branchSearchInput?.focus())
}}
>
<Menu placement="bottom" gutter={4} onOpenChange={onOpenChange}>
<Menu.Trigger class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-background-bg-layer-03 hover:text-v2-text-text-muted focus-visible:bg-v2-background-bg-layer-03 focus-visible:text-v2-text-text-muted focus-visible:outline-none data-[expanded]:bg-v2-background-bg-layer-03 data-[expanded]:text-v2-text-text-muted">
<Icon name="branch-out" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span ref={branchTruncation.observe} class="min-w-0 truncate">
@@ -258,7 +251,14 @@ export function PromptWorkspaceSelector(props: {
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</Menu.Trigger>
<Menu.Portal>
<Menu.Content class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none">
<Menu.Content
class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none"
onOpenAutoFocus={(event) => {
event.preventDefault()
// Kobalte defers its list autofocus until after the focus scope opens.
setTimeout(() => requestAnimationFrame(() => branchSearchInput?.focus({ preventScroll: true })))
}}
>
<div class="flex h-7 shrink-0 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
<Icon name="magnifying-glass" size="small" class="shrink-0" />
<input
@@ -34,6 +34,27 @@ function setup(input?: {
}
describe("createRequestQueue", () => {
test("starts a free slot before the caller continues its synchronous work", async () => {
const input = setup()
const response = input.queue.fetch("http://server/api/session")
expect(input.pending.map((item) => new URL(item.url).pathname)).toEqual(["/api/session"])
expect(input.queue.inflight()).toBe(1)
input.pending[0]!.resolve()
await response
expect(input.queue.inflight()).toBe(0)
})
test("releases a free slot without sending an already-aborted request", async () => {
const input = setup()
const controller = new AbortController()
controller.abort()
await expect(input.queue.fetch("http://server/api/session", { signal: controller.signal })).rejects.toBeInstanceOf(
DOMException,
)
expect(input.pending).toHaveLength(0)
expect(input.queue.inflight()).toBe(0)
})
test("caps concurrent requests and starts queued ones as slots free up", async () => {
const input = setup()
const responses = ["/api/a", "/api/b", "/api/c"].map((path) => input.queue.fetch(`http://server${path}`))
@@ -109,12 +130,10 @@ describe("createRequestQueue", () => {
const input = setup({ limit: 1, headersTimeoutMs: 10 })
const dead = input.queue.fetch("http://server/api/dead")
const next = input.queue.fetch("http://server/api/next")
await input.settle()
expect(input.queue.queued()).toBe(1)
const error = await dead.catch((cause: unknown) => cause)
expect(error).toBeInstanceOf(DOMException)
expect((error as DOMException).name).toBe("TimeoutError")
await input.settle()
expect(input.pending.map((item) => new URL(item.url).pathname)).toEqual(["/api/dead", "/api/next"])
input.pending[1]!.resolve()
await expect(next).resolves.toBeInstanceOf(Response)
@@ -84,17 +84,23 @@ export function createRequestQueue(input: {
if (index === -1) return
waiting.splice(index, 1)[0]?.start()
}
const acquire = (entry: Entry) =>
new Promise<void>((resolve) => {
const acquire = (entry: Entry) => {
// A free slot must start fetch before the caller's synchronous UI work.
// Awaiting an already-resolved promise postpones that dispatch until after it.
if (canStart(entry)) {
inflight.add(entry)
return
}
return new Promise<void>((resolve) => {
const start = () => {
entry.at = now()
inflight.add(entry)
resolve()
}
if (canStart(entry)) return start()
waiting.push({ entry, start })
watcher ??= setTimeout(watch, stallMs)
})
}
const fetch: typeof globalThis.fetch = Object.assign(
async (resource: RequestInfo | URL, init?: RequestInit) => {
@@ -103,7 +109,8 @@ export function createRequestQueue(input: {
// The event stream is long-lived; never count it against the request budget.
if (pathname === "/api/event") return base(request)
const entry = { method: request.method, url: request.url, at: now(), slow: isSlowRequest(pathname) }
await acquire(entry)
const queued = acquire(entry)
if (queued) await queued
if (request.signal.aborted) {
release(entry)
throw request.signal.reason ?? new DOMException("The operation was aborted.", "AbortError")
@@ -16,6 +16,8 @@ import { ModelState } from "./persistence"
import { useLanguage } from "@/runtime/i18n/language"
import { showToast } from "@/shell/notifications/toast"
import { formatServerError } from "./errors"
import { useSettings } from "@/settings/model"
import { timelinePreset } from "@opencode/session-ui/timeline/detail"
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
name: "Global",
@@ -131,10 +133,12 @@ function createServerController(
projects: ReturnType<typeof createServerProjects>,
) {
const language = useLanguage()
const settings = useSettings()
const connKey = ServerConnection.key(conn)
const sdk = createServerSdkContext(conn, scope)
const source = createData({
api: () => sdk.api,
initialMessageLimit: () => (timelinePreset(settings.general.timelineDetail())?.id === "compact" ? 40 : 20),
event: {
on: sdk.event.on,
listen: (handler) => sdk.event.listen((event) => handler({ name: event.type, details: event })),
+7 -1
View File
@@ -108,6 +108,13 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
}
createEffect(() => !store.editing && setStore("address", address()))
createEffect(
on(registration, (current) => {
// Session routes can change before this pane unmounts. Hide the registration
// that owned the native view, rather than reading the destination's handle.
onCleanup(() => current?.setLayout())
}),
)
createEffect(
on(
[
@@ -140,7 +147,6 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
createEventListener(document, "visibilitychange", () => setStore("visible", document.visibilityState === "visible"))
onCleanup(() => {
if (frame !== undefined) cancelAnimationFrame(frame)
registration()?.setLayout()
})
return (
@@ -6,7 +6,7 @@ import { StatusPopover } from "@/shell/status/status-popover"
import { TitlebarRight } from "@/shell/titlebar/right-slot"
import { Tooltip } from "@opencode/ui/tooltip"
export function SessionHeader() {
export function SessionHeader(props: { reserveReviewToggle: boolean }) {
const language = useLanguage()
const settings = useSettings()
@@ -21,8 +21,7 @@ export function SessionHeader() {
</Tooltip>
</Show>
</TitlebarRight>
{/* Keep the fixed toggle's slot mounted throughout panel motion. */}
<Show when={isDesktop()}>
<Show when={isDesktop() && props.reserveReviewToggle}>
<div class="size-7 shrink-0" aria-hidden />
</Show>
</>
+41 -30
View File
@@ -34,6 +34,7 @@ import { SessionIdentityHeader } from "./session-identity-header"
import { SessionReviewToggle } from "./header/session-header-actions"
import { createAnimatedPresence } from "@/runtime/animated-presence"
import { createSessionBrowser } from "./browser/model"
import { createTimelineCache } from "./timeline/cache"
const SessionMobileFiles = lazy(async () => {
const { SessionMobileFiles } = await import("./files/session-mobile-files")
@@ -219,6 +220,45 @@ export function SessionScreen(props: { session: SessionModel }) {
</Show>
)
const timelineView = createTimelineCache(
session,
(source, active) => (
<MessageTimeline
active={active()}
hideHeader={!isDesktop()}
session={source}
background={composer.requests.background}
actions={composer.actions.timeline}
scroll={timeline.scroll}
onResumeScroll={timeline.actions.resume}
setScrollRef={timeline.view.setScrollRef}
onScheduleScrollState={timeline.view.scheduleScrollState}
onPin={timeline.view.pin}
onUnpin={timeline.view.unpin}
onUserScroll={timeline.view.markUserScroll}
onHistoryScroll={timeline.view.onHistoryScroll}
onSelectionInteraction={timeline.view.selectionInteraction}
pinned={timeline.view.pinned()}
centered={screen.centered()}
reserveReviewToggle={!sideVisible()}
setContentRef={timeline.view.setContentRef}
diffs={review.details.diffs}
onReview={review.open}
workspaceMoveEligible={composer.workspaceMoveEligible()}
onSummaryOpenChange={review.details.setOpen}
anchor={timeline.view.anchor}
setRevealMessage={timeline.view.setRevealMessage}
setScrollToEnd={timeline.view.setScrollToEnd}
search={
<Show when={active()}>
<TimelineSearchBar controller={timelineSearch} />
</Show>
}
/>
),
() => conversationVisible() && messagesReady(),
)
const sessionPanelContent = () => (
<>
<ComposerDropzone
@@ -258,36 +298,7 @@ export function SessionScreen(props: { session: SessionModel }) {
<Show when={isDesktop() && !messagesReady()}>
<SessionIdentityHeader sessionID={session.identity.params.id ?? ""} session={session.data.info()} />
</Show>
<Show when={messagesReady() ? session.identity.params.id : undefined} keyed>
{(_id) => (
<MessageTimeline
hideHeader={!isDesktop()}
session={session}
background={composer.requests.background}
actions={composer.actions.timeline}
scroll={timeline.scroll}
onResumeScroll={timeline.actions.resume}
setScrollRef={timeline.view.setScrollRef}
onScheduleScrollState={timeline.view.scheduleScrollState}
onPin={timeline.view.pin}
onUnpin={timeline.view.unpin}
onUserScroll={timeline.view.markUserScroll}
onHistoryScroll={timeline.view.onHistoryScroll}
onSelectionInteraction={timeline.view.selectionInteraction}
pinned={timeline.view.pinned()}
centered={screen.centered()}
setContentRef={timeline.view.setContentRef}
diffs={review.details.diffs}
onReview={review.open}
workspaceMoveEligible={composer.workspaceMoveEligible()}
onSummaryOpenChange={review.details.setOpen}
anchor={timeline.view.anchor}
setRevealMessage={timeline.view.setRevealMessage}
setScrollToEnd={timeline.view.setScrollToEnd}
search={<TimelineSearchBar controller={timelineSearch} />}
/>
)}
</Show>
<Show when={messagesReady() && session.identity.params.id}>{timelineView()}</Show>
</Match>
</Switch>
</div>
@@ -0,0 +1,58 @@
import {
createComputed,
createMemo,
createRoot,
getOwner,
on,
onCleanup,
untrack,
type Accessor,
type JSX,
} from "solid-js"
import { createScopedCache } from "@/runtime/server/scoped-cache"
import type { TimelineSessionSource } from "./controller"
export function createTimelineCache(
session: TimelineSessionSource & { identity: { workspaceKey: Accessor<string> } },
render: (source: TimelineSessionSource, active: Accessor<boolean>) => JSX.Element,
visible: Accessor<boolean>,
) {
const owner = getOwner()
const cache = createScopedCache(
(key) =>
createRoot((dispose) => {
const id = untrack(session.identity.sessionID)
const sessionKey = untrack(session.identity.sessionKey)
const active = createMemo(() => visible() && session.identity.sessionKey() === key)
// A detached view retains its own inputs until it is selected again.
const select = <T>(read: Accessor<T>) =>
createMemo<T>((previous) => (active() ? read() : previous), untrack(read))
return {
value: render(
{
identity: {
params: session.identity.params,
sessionID: () => id,
sessionKey: () => sessionKey,
},
data: {
info: select(session.data.info),
parent: select(session.data.parent),
parentID: select(session.data.parentID),
status: select(session.data.status),
},
history: { messages: select(session.history.messages) },
},
active,
),
dispose,
}
}, owner),
{ maxEntries: 16, dispose: (entry) => entry.dispose() },
)
onCleanup(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, cache.clear, { defer: true }))
return () => cache.get(session.identity.sessionKey()).value
}
@@ -349,6 +349,7 @@ export function SessionSummaryPanel(props: {
type MessageTimelineProps = {
hideHeader?: boolean
active?: boolean
session: TimelineSessionSource
background: SessionBackground
actions?: SessionUserActions
@@ -363,6 +364,7 @@ type MessageTimelineProps = {
onSelectionInteraction: (event: MouseEvent) => void
pinned: boolean
centered: boolean
reserveReviewToggle: boolean
setContentRef: (el: HTMLDivElement) => void
diffs: Accessor<{ additions: number; deletions: number }[] | undefined>
onReview: () => void
@@ -458,6 +460,7 @@ function MessageTimelineView(
const pinned = createMemo(() => props.pinned)
const messageByID = projection.messageByID
const virtualized = createTimelineVirtualizer({
active: () => props.active !== false,
sessionKey: () => `${server.key}/${props.data.sessionID()}`,
presentationKey: () => JSON.stringify(props.data.timelineDetail()),
projection,
@@ -551,6 +554,12 @@ function MessageTimelineView(
if (await props.action.rename(title.draft)) setTitle("editing", false)
}
createEffect(() => {
if (props.active !== false) return
setSummary(false)
setTitle({ draft: "", editing: false, menuOpen: false, pendingRename: false })
})
const rowRenderer = createSessionTimelineRowRenderer({
sessionID: () => sessionID()!,
status: sessionStatus,
@@ -845,7 +854,7 @@ function MessageTimelineView(
</Popover>
)}
</Show>
<SessionHeader />
<SessionHeader reserveReviewToggle={props.reserveReviewToggle} />
</div>
)}
</Show>
+5 -4
View File
@@ -11,17 +11,17 @@ export {
selectVisibleSessionUserMessages as selectVisibleUserMessages,
} from "../session-domain"
export function createTimelineModel(input: { session: Pick<SessionModel, "identity" | "history"> }) {
export function createTimelineModel(input: { session: Pick<SessionModel, "identity" | "history" | "ownership"> }) {
const data = useData()
const [resource] = createResource(
() => input.session.identity.sessionID(),
async (id) => {
if (!id) return
const key = input.session.identity.sessionKey()
const owner = input.session.ownership.capture()
await Promise.all([data.session.message.sync(id), data.session.pending.sync(id)])
await enrichLeadingTurn({
current: () => input.session.identity.sessionKey() === key,
current: owner.current,
messages: () => data.session.message.list(id),
more: () => data.session.message.more(id),
loading: () => data.session.message.loading(id),
@@ -29,12 +29,13 @@ export function createTimelineModel(input: { session: Pick<SessionModel, "identi
pause: () => new Promise((resolve) => setTimeout(resolve, leadingTurnPageDelay)),
maxPages: leadingTurnPageLimit,
}).catch(() => undefined)
return id
},
)
const ready = createMemo(() => {
const id = input.session.identity.sessionID()
// Enrich the partial leading group without withholding the already loaded tail.
return !id || data.session.message.list(id).length > 0 || !resource.loading
return !id || data.session.message.list(id).length > 0 || (!resource.loading && resource.latest === id)
})
const more = () => {
const id = input.session.identity.sessionID()
@@ -16,6 +16,35 @@ test("matches only the scroll element or an ancestor containing it", () => {
expect(mutationNodesContainElement([child, sibling], viewport)).toBe(false)
})
test("restores a view observed before its first attachment", async () => {
const targetWindow = new Window()
const mutations = controlledMutations(targetWindow)
const viewport = targetWindow.document.createElement("div")
const instance = {
scrollElement: viewport,
targetWindow,
scrollOffset: 240,
options: { horizontal: false, isRtl: false, isScrollingResetDelay: 0, useScrollendEvent: false },
} as unknown as Virtualizer<HTMLDivElement, HTMLDivElement>
const connections: boolean[] = []
const cleanup = observeElementOffsetReconnectAware(
instance,
(offset) => {
instance.scrollOffset = offset
},
() => connections.push(viewport.isConnected),
)
try {
mutations.append(targetWindow.document.body, viewport)
await frames(2, targetWindow)
expect(connections).toEqual([true])
expect(instance.scrollOffset).toBe(0)
} finally {
cleanup()
await targetWindow.happyDOM.close()
}
})
test("reports a divergent native offset once and ignores equal offsets and unrelated mutations", async () => {
const targetWindow = new Window()
const mutations = controlledMutations(targetWindow)
@@ -20,7 +20,8 @@ export function observeElementOffsetReconnectAware<TScrollElement extends Elemen
cleanupOffset?.()
}
let removed = false
// Cached views can be constructed before their first attachment to the page.
let removed = !element.isConnected
let frame: number | undefined
const clearCheck = () => {
if (frame === undefined) return
@@ -2,6 +2,7 @@ import {
createVirtualizer,
defaultRangeExtractor,
elementScroll,
observeElementRect,
type Range,
type VirtualItem,
} from "@tanstack/solid-virtual"
@@ -49,6 +50,7 @@ type Projection = Pick<
>
type Input = {
active?: Accessor<boolean>
sessionKey: Accessor<string>
presentationKey?: Accessor<string>
projection: Projection
@@ -83,6 +85,7 @@ type ViewProps = {
export function createTimelineVirtualizer(input: Input) {
const language = useLanguage()
const active = () => input.active?.() !== false
const isDesktop = createMediaQuery("(min-width: 768px)")
const topOffset = () => (input.showHeader() ? 64 : isDesktop() ? 0 : 16)
const ownerSessionKey = input.sessionKey()
@@ -134,7 +137,7 @@ export function createTimelineVirtualizer(input: Input) {
!(
row._tag === "AssistantPart" &&
row.group.type === "context" &&
row.group.refs.length <= 16 &&
row.group.refs.length <= 64 &&
!toolOpen[`context:${row.group.key}`]
) && !input.canRenderImmediately?.(row, toolOpen),
)
@@ -156,6 +159,7 @@ export function createTimelineVirtualizer(input: Input) {
let virtualContent: HTMLDivElement | undefined
let scrollTop = 0
let reportOffset: ((offset: number, scrolling: boolean) => void) | undefined
let reportRect: ((rect: { width: number; height: number }) => void) | undefined
let batchingColdSizes = false
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
@@ -163,13 +167,22 @@ export function createTimelineVirtualizer(input: Input) {
return rows().length
},
getScrollElement: () => listRoot() ?? null,
observeElementRect: (instance, callback) => {
reportRect = callback
return observeElementRect(instance, (rect) => {
if (active()) callback(rect)
})
},
// Route navigation detaches and reattaches the scroll element, which drops its offset.
observeElementOffset: (instance, callback) => {
reportOffset = (offset, scrolling) => {
if (!active()) return
callback(offset, scrolling)
settleColdBottom()
}
return observeElementOffsetReconnectAware(instance, reportOffset, () => {
if (!active()) return
virtualContent?.querySelectorAll<HTMLDivElement>("[data-index]").forEach(virtualizer.measureElement)
if (input.pinned()) virtualizer.scrollToEnd()
settleColdBottom()
})
@@ -181,6 +194,11 @@ export function createTimelineVirtualizer(input: Input) {
// its snapshot on attachment, but later explicit measurements must read layout
// so deferred/rewrapped content cannot keep stale, clipped heights (TanStack/virtual#1183).
measureElement: (element, entry, instance) => {
if (!active() || !element.isConnected)
return (
instance.itemSizeCache.get(instance.options.getItemKey(instance.indexFromElement(element))) ??
fallbackItemSize
)
const initial = !measuredElements.has(element)
measuredElements.add(element)
const box = entry?.borderBoxSize[0]
@@ -192,6 +210,7 @@ export function createTimelineVirtualizer(input: Input) {
return element.offsetHeight
},
scrollToFn: (offset, options, instance) => {
if (!active()) return
if (batchingColdSizes && input.pinned()) return
if (virtualContent) virtualContent.style.height = `${instance.getTotalSize()}px`
elementScroll(offset, options, instance)
@@ -221,6 +240,7 @@ export function createTimelineVirtualizer(input: Input) {
// Read the whole measurement delivery before committing reactive row sizes.
// Otherwise each row can render and force layout before the next is measured.
virtualizer.resizeItem = (index, size) => {
if (!active()) return
const row = rows()[index]
if (!row) return
const key = TimelineRow.key(row)
@@ -236,6 +256,7 @@ export function createTimelineVirtualizer(input: Input) {
if (!pendingSizes.size) return
const sizes = [...pendingSizes]
pendingSizes.clear()
if (!active()) return
// The hidden pinned mount needs one bottom write after the whole batch,
// not a layout-forcing scroll adjustment for every measured row.
batchingColdSizes = coldPending && input.pinned()
@@ -271,7 +292,21 @@ export function createTimelineVirtualizer(input: Input) {
const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => String(item.key)))
createEffect(() => {
if (!active()) return
const root = listRoot()
if (root) input.setScrollRef(root)
if (virtualContent) input.setContentRef(virtualContent)
queueMicrotask(() => {
if (!active() || !root?.isConnected) return
// A detached view can miss its nonzero ResizeObserver delivery. Publish
// its real viewport before restoring the offset and admitting rows.
reportRect?.({ width: root.offsetWidth, height: root.offsetHeight })
if (input.pinned()) virtualizer.scrollToEnd()
reportOffset?.(root.scrollTop, false)
settleColdBottom()
})
input.setRevealMessage?.((id, partID) => {
if (!active()) return
const partIndex = partID
? rows().findIndex(
(row) => row._tag === "AssistantPart" && row.group.type === "part" && row.group.ref.partID === partID,
@@ -282,6 +317,7 @@ export function createTimelineVirtualizer(input: Input) {
virtualizer.scrollToIndex(index, { align: "center" })
})
input.setScrollToEnd?.(() => {
if (!active() || !listRoot()?.isConnected) return
input.onPin()
virtualizer.scrollToEnd()
})
@@ -292,6 +328,7 @@ export function createTimelineVirtualizer(input: Input) {
let contentObserver: MutationObserver | undefined
let viewportObserver: ResizeObserver | undefined
const pinColdBottom = () => {
if (!active()) return
const root = listRoot()
if (!input.pinned() || !virtualContent || !root) return
// scrollToEnd computes its target from the DOM, not the new size cache.
@@ -309,7 +346,7 @@ export function createTimelineVirtualizer(input: Input) {
)
}
const settleColdBottom = () => {
if (!coldPending || settleQueued) return
if (!active() || !coldPending || settleQueued) return
settleQueued = true
queueMicrotask(() => {
settleQueued = false
@@ -372,7 +409,7 @@ export function createTimelineVirtualizer(input: Input) {
setListRoot(root)
scrollTop = root.scrollTop
maxScroll = root.scrollHeight - root.clientHeight
input.setScrollRef(root)
if (active()) input.setScrollRef(root)
viewportObserver?.observe(root)
settleColdBottom()
}
@@ -429,6 +466,7 @@ export function createTimelineVirtualizer(input: Input) {
// under a viewport that was already there. Merely resting near the end is not enough, otherwise
// a later scroll would overwrite an upward intent expressed a pixel short of the bottom.
const handleListScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
if (!active()) return
const root = event.currentTarget
const previousTop = scrollTop
const previousMaxScroll = maxScroll
@@ -454,15 +492,8 @@ export function createTimelineVirtualizer(input: Input) {
let contentMeasureFrame: number | undefined
onMount(() => virtualizer.measureElement(element))
createEffect(
on(
() => item().index,
() => {
virtualizer.measureElement(element)
},
{ defer: true },
),
)
// Prepending history changes data-index, not the keyed element's identity.
// Its observer reads the current index and delivers any actual size change.
onCleanup(() => {
if (contentMeasureFrame !== undefined) cancelAnimationFrame(contentMeasureFrame)
queueMicrotask(() => virtualizer.measureElement(null))
@@ -493,7 +524,7 @@ export function createTimelineVirtualizer(input: Input) {
if (contentMeasureFrame !== undefined) cancelAnimationFrame(contentMeasureFrame)
contentMeasureFrame = requestAnimationFrame(() => {
contentMeasureFrame = undefined
if (element.isConnected) virtualizer.measureElement(element)
if (active() && element.isConnected) virtualizer.measureElement(element)
})
})}
</div>
@@ -555,7 +586,7 @@ export function createTimelineVirtualizer(input: Input) {
data-timeline-virtual-content
ref={(element) => {
virtualContent = element
input.setContentRef(element)
if (active()) input.setContentRef(element)
}}
style={{
height: `${virtualizer.getTotalSize()}px`,
@@ -590,9 +621,11 @@ export function createTimelineVirtualizer(input: Input) {
coldPending = false
contentObserver?.disconnect()
viewportObserver?.disconnect()
input.setScrollRef(undefined)
input.setRevealMessage?.(() => {})
input.setScrollToEnd?.(() => {})
if (active()) {
input.setScrollRef(undefined)
input.setRevealMessage?.(() => {})
input.setScrollToEnd?.(() => {})
}
})
return {
+5 -1
View File
@@ -288,8 +288,12 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
createEffect(() => {
if (typeof document === "undefined") return
const root = document.documentElement
root.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.mono))
const mono = monoFontFamily(store.appearance?.mono)
root.style.setProperty("--font-family-mono", mono)
root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.sans))
// Inline code can first appear during history backfill. Load its selected
// face with the shell so that font discovery does not resize that mount.
void document.fonts?.load(`440 13px ${mono}`).catch(() => undefined)
})
return {
+2
View File
@@ -51,6 +51,8 @@
flex: 1;
min-width: 0;
max-width: 720px;
/* Leave room for raised card shadows inside the scrollport. */
padding-inline: 4px;
}
.settings-screen .settings-tab-header {
+11 -10
View File
@@ -1,7 +1,7 @@
import { createSimpleContext } from "@opencode/ui/context"
import { useDialog } from "@opencode/ui/context/dialog"
import { type Accessor, createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { type Accessor, batch, createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { makeEventListener } from "@solid-primitives/event-listener"
@@ -306,19 +306,20 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
createEffect(() => {
if (!catalogReady()) return
setCatalog(
registered().reduce((acc, opt) => {
const id = actionId(opt.id)
if (opt.title)
acc[id] = {
batch(() =>
registered().forEach((opt) => {
if (!opt.title) return
setCatalog(
actionId(opt.id),
reconcile({
title: opt.title,
description: opt.description,
category: opt.category,
keybind: opt.keybind,
slash: opt.slash,
}
return acc
}, {} as CommandCatalog),
}),
)
}),
)
})
@@ -0,0 +1,41 @@
import { expect, test } from "bun:test"
import { createComputed, createRoot } from "solid-js"
import { OpenCode } from "@opencode/client/promise"
import { createData } from "@opencode/client/solid"
test("publishes an initial message page, its index, and its cursor together", async () => {
const observed: { ids: string[]; more: boolean; text: string | undefined }[] = []
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async () =>
Response.json({
data: [{ id: "msg_page", type: "user", text: "History", time: { created: 1 } }],
cursor: { next: "older" },
}),
})
const setup = createRoot((dispose) => {
const data = createData({
api: () => api,
directory: "/project",
event: { on: () => () => {}, listen: () => () => {} },
})
createComputed(() => {
const message = data.session.message.get("ses_page", "msg_page")
observed.push({
ids: data.session.message.list("ses_page").map((message) => message.id),
more: data.session.message.more("ses_page"),
text: message?.type === "user" ? message.text : undefined,
})
})
return { data, dispose }
})
try {
await setup.data.session.message.sync("ses_page")
expect(observed).toEqual([
{ ids: [], more: false, text: undefined },
{ ids: ["msg_page"], more: true, text: "History" },
])
} finally {
setup.dispose()
}
})
@@ -0,0 +1,167 @@
import { expect, test } from "bun:test"
import type { SessionMessageInfo } from "@opencode/client/promise"
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"
function setup() {
return createRoot((dispose) => {
const [state, setState] = createStore({
id: "ses_a",
directory: "/repo",
visible: true,
messages: {
ses_a: [{ id: "msg_a", type: "user", text: "First session", time: { created: 1 } }],
ses_b: [{ id: "msg_b", type: "user", text: "Second session", time: { created: 2 } }],
} as Record<string, SessionMessageInfo[]>,
})
const views = new Map<
string,
{ active: () => boolean; messages: () => SessionMessageInfo[]; element: HTMLDivElement }
>()
const disposed: string[] = []
const cache = createTimelineCache(
{
identity: {
params: {
get id() {
return state.id
},
serverKey: "local",
},
sessionID: () => state.id,
workspaceKey: () => SessionStateKey.from(ServerScope.local, SessionRouteKey.fromRoute(state.directory)),
sessionKey: () =>
SessionStateKey.from(ServerScope.local, SessionRouteKey.fromRoute(state.directory, state.id)),
},
data: {
info: createMemo(() => ({
id: state.id,
projectID: "project",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
location: { directory: state.directory },
})),
parent: () => undefined,
parentID: () => undefined,
status: () => ({ type: "idle" }),
},
history: { messages: () => state.messages[state.id] ?? [] },
},
(source, active) => {
const id = source.identity.sessionID()!
const element = document.createElement("div")
createMemo(() =>
element.setAttribute(
"data-messages",
source.history
.messages()
.map((message) => message.id)
.join(","),
),
)
views.set(id, { active, messages: source.history.messages, element })
onCleanup(() => disposed.push(id))
return element
},
() => state.visible,
)
return { state, setState, cache, views, disposed, dispose }
})
}
test("reuses a session view and refreshes its own history when selected again", () => {
const input = setup()
try {
const first = input.cache()
input.setState("id", "ses_b")
const second = input.cache()
expect(second).not.toBe(first)
expect(input.views.get("ses_a")!.active()).toBe(false)
expect(input.views.get("ses_a")!.element.dataset.messages).toBe("msg_a")
expect(input.views.get("ses_b")!.element.dataset.messages).toBe("msg_b")
input.setState("messages", "ses_a", [
{ id: "msg_c", type: "user", text: "Updated while inactive", time: { created: 3 } },
])
input.setState("id", "ses_a")
expect(input.cache()).toBe(first)
expect(input.views.get("ses_a")!.active()).toBe(true)
expect(input.views.get("ses_a")!.element.dataset.messages).toBe("msg_c")
expect(input.views.get("ses_b")!.element.dataset.messages).toBe("msg_b")
expect(input.disposed).toEqual([])
} finally {
input.dispose()
}
expect(input.disposed.sort()).toEqual(["ses_a", "ses_b"])
})
test("suspends a detached mobile view and reuses it when the conversation returns", () => {
const input = setup()
try {
const first = input.cache()
input.setState("visible", false)
expect(input.views.get("ses_a")!.active()).toBe(false)
input.setState("visible", true)
expect(input.cache()).toBe(first)
expect(input.views.get("ses_a")!.active()).toBe(true)
} finally {
input.dispose()
}
})
test("disposes views whose Location-scoped providers no longer match", () => {
const input = setup()
try {
const first = input.cache()
input.setState("directory", "/other")
expect(input.cache()).not.toBe(first)
expect(input.disposed).toEqual(["ses_a"])
input.setState("directory", "/repo")
expect(input.cache()).not.toBe(first)
expect(input.disposed).toEqual(["ses_a", "ses_a"])
} finally {
input.dispose()
}
expect(input.disposed).toHaveLength(3)
})
test("disposes views on workspace changes while the destination is not rendered", () => {
const input = setup()
try {
const first = input.cache()
input.setState("visible", false)
input.setState("directory", "/other")
expect(input.disposed).toEqual(["ses_a"])
input.setState("directory", "/repo")
input.setState("visible", true)
expect(input.cache()).not.toBe(first)
} finally {
input.dispose()
}
expect(input.disposed).toEqual(["ses_a", "ses_a"])
})
test("evicts the least recently selected view and disposes all retained owners", () => {
const input = setup()
try {
const first = input.cache()
Array.from({ length: 15 }, (_, index) => `ses_${index}`).forEach((id) => {
input.setState("id", id)
input.cache()
})
input.setState("id", "ses_a")
expect(input.cache()).toBe(first)
input.setState("id", "ses_last")
input.cache()
expect(input.disposed).toEqual(["ses_0"])
input.setState("id", "ses_0")
input.cache()
expect(input.disposed).toEqual(["ses_0", "ses_1"])
} finally {
input.dispose()
}
expect(input.disposed).toHaveLength(18)
})
+5 -1
View File
@@ -100,7 +100,11 @@ function packageNames() {
function copyBinary(source) {
if (!fs.existsSync(source)) throw new Error(`Binary not found at ${source}`)
fs.mkdirSync(path.dirname(targetBinary), { recursive: true })
if (fs.existsSync(targetBinary)) fs.unlinkSync(targetBinary)
if (fs.existsSync(targetBinary)) {
try {
fs.unlinkSync(targetBinary)
} catch {}
}
try {
fs.linkSync(source, targetBinary)
} catch {
+20 -1
View File
@@ -119,7 +119,26 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
commands: [
Spec.make("agents", { description: "List all agents" }),
Spec.make("config", { description: "List configuration sources" }),
Spec.make("paths", { description: "Show global paths (data, config, cache, state)" }),
Spec.make("paths", {
description: "Show global paths (data, config, cache, state)",
params: {
name: Argument.choice("name", [
"db",
"home",
"data",
"config",
"cache",
"state",
"tmp",
"bin",
"log",
"repos",
]).pipe(
Argument.withDescription("Print only one path: db, home, data, config, cache, state, tmp, bin, log, repos"),
Argument.optional,
),
},
}),
],
}),
Spec.make("auth", {
@@ -1,15 +1,21 @@
import { EOL } from "os"
import { Effect } from "effect"
import { Effect, Option } from "effect"
import { Global } from "@opencode/util/global"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { databasePath } from "../../../database-path"
export default Runtime.handler(
Commands.commands.debug.commands.paths,
Effect.fn("cli.debug.paths")(function* () {
Effect.fn("cli.debug.paths")(function* (input) {
const global = yield* Global.Service
const paths = { ...global, db: databasePath(global.data) }
if (Option.isSome(input.name)) {
process.stdout.write(paths[input.name.value] + EOL)
return
}
process.stdout.write(
Object.entries(global)
Object.entries(paths)
.map(([key, value]) => `${key.padEnd(10)} ${value}${EOL}`)
.join(""),
)
+13
View File
@@ -0,0 +1,13 @@
import path from "node:path"
import { OPENCODE_CHANNEL } from "./version"
export function databasePath(data: string) {
const filename =
process.env.OPENCODE_DB ??
(["latest", "dev", "beta", "next", "prod"].includes(OPENCODE_CHANNEL) ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
? "opencode.db"
: `opencode-${OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`)
return filename === ":memory:" ? filename : path.resolve(data, filename)
}
+2 -7
View File
@@ -15,6 +15,7 @@ import { ServiceConfig } from "./services/service-config"
import { ServiceRegistration } from "./services/service-registration"
import { Updater } from "./services/updater"
import { WebUi } from "./services/web-ui"
import { databasePath } from "./database-path"
export type Mode = "default" | "service" | "stdio"
@@ -94,13 +95,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
pty: { handoff },
simulation: truthy(process.env.OPENCODE_SIMULATE),
database: {
path:
process.env.OPENCODE_DB ??
(["latest", "dev", "beta", "next", "prod"].includes(OPENCODE_CHANNEL) ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
? "opencode.db"
: `opencode-${OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`),
path: databasePath(global.data),
},
models: {
url: process.env.OPENCODE_MODELS_URL,
+7 -2
View File
@@ -167,6 +167,11 @@ const make = Effect.gen(function* () {
const latest = () => release().pipe(Effect.map((data) => data.version))
const temporaryDirectory = (prefix: string) =>
Effect.acquireRelease(fs.makeTempDirectory({ directory: global.cache, prefix }), (directory) =>
fs.remove(directory, { recursive: true, force: true }).pipe(Effect.ignore),
)
const upgrade = Effect.fnUntraced(function* (method: Method, input: string) {
if (!parseReleaseVersion(input)) return yield* Effect.fail(new Error(`Invalid version: ${input}`))
const version = input.trim().replace(/^v/, "")
@@ -192,12 +197,12 @@ const make = Effect.gen(function* () {
if (method === "bun") {
// Bun does not prune old versions from its shared package cache.
yield* fs.makeDirectory(global.cache, { recursive: true })
const cache = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
const cache = yield* temporaryDirectory("update-")
return yield* exec(["bun", "install", "--global", "--trust", "--cache-dir", cache, target], "5 minutes")
}
if (method === "curl") {
yield* fs.makeDirectory(global.cache, { recursive: true })
const directory = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
const directory = yield* temporaryDirectory("update-")
const installer = path.join(directory, "install")
const download = yield* exec(
["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"],
+21 -1
View File
@@ -2,7 +2,7 @@ import { NodeServices } from "@effect/platform-node"
import { Global } from "@opencode/util/global"
import { AppProcess } from "@opencode/util/process"
import { expect, spyOn, test } from "bun:test"
import { Effect, FileSystem, Stream } from "effect"
import { Effect, FileSystem, PlatformError, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { existsSync } from "node:fs"
import path from "node:path"
@@ -18,6 +18,7 @@ function fixture(
error?: AppProcess.AppProcessError
} = () => ({}),
name = "@opencode/cli",
failCleanup = false,
) {
return Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
@@ -57,6 +58,17 @@ function fixture(
Effect.provideService(Global.Service, global),
Effect.provideService(FileSystem.FileSystem, {
...fs,
remove: (target, options) =>
failCleanup && target.startsWith(global.cache)
? Effect.fail(
PlatformError.systemError({
_tag: "PermissionDenied",
module: "FileSystem",
method: "remove",
pathOrDescriptor: target,
}),
)
: fs.remove(target, options),
realPath: (input) => (input === process.execPath ? Effect.succeed(executable) : fs.realPath(input)),
}),
Effect.provideService(
@@ -125,6 +137,14 @@ installs.forEach(({ method, command }) => {
}),
)
})
it.live("bun ignores install cache cleanup failures", () =>
Effect.gen(function* () {
const test = yield* fixture(() => ({}), "@opencode/cli", true)
yield* test.updater.upgrade("bun", "v2.3.4-beta.1")
expect(test.commands).toHaveLength(1)
}),
)
;["success", "download", "install"].forEach((failure) => {
it.live(`curl uses the V2 installer and cleans its directory: ${failure}`, () =>
Effect.gen(function* () {
@@ -1369,6 +1369,7 @@ export type ProviderInfo = {
activation: "auto" | "enabled" | "disabled"
package: string
compaction?: ProviderCompaction
websocket?: boolean
settings?: { [x: string]: any }
headers?: { [x: string]: string }
body?: { [x: string]: any }
@@ -1849,6 +1850,7 @@ export type ModelInfo = {
compatibility?: ModelCompatibility
package?: string
compaction?: ProviderCompaction
websocket?: boolean
settings?: { [x: string]: any }
headers?: { [x: string]: string }
body?: { [x: string]: any }
@@ -2025,6 +2027,7 @@ export type ConfigEntry =
providers?: {
[x: string]: {
compaction?: ProviderCompaction
websocket?: boolean
canonical?: string
name?: string
env?: Array<string>
@@ -2035,6 +2038,7 @@ export type ConfigEntry =
models?: {
[x: string]: {
compaction?: ProviderCompaction
websocket?: boolean
modelID?: string
family?: string
name?: string
+12 -4
View File
@@ -57,6 +57,8 @@ type OpenCodeEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent
export type CreateDataInput = {
readonly api: () => OpenCodeClient
readonly directory: string
/** Raw-message window used for an initial transcript read. Older pages retain their normal size. */
readonly initialMessageLimit?: () => number
readonly event: {
readonly on: <Type extends OpenCodeEvent["type"]>(
type: Type,
@@ -1569,7 +1571,11 @@ export function createData(config: CreateDataInput) {
},
sync(sessionID: string) {
return sync.run(`session.message:${sessionID}`, async () => {
const response = await api().message.list({ sessionID, limit: messagePageLimit, order: "desc" })
const response = await api().message.list({
sessionID,
limit: config.initialMessageLimit?.() ?? messagePageLimit,
order: "desc",
})
const fetched = response.data.toReversed()
// Same protection as the pending sync: a re-fetch racing an
// admission must not wipe its local transcript row.
@@ -1583,9 +1589,11 @@ export function createData(config: CreateDataInput) {
(item) => !ids.has(item.id) && (outbox.has(item.id) || admitted.has(item.id)),
)
const messages = local.length === 0 ? fetched : [...fetched, ...local]
messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])))
setStore("session", "message", sessionID, reconcile(messages))
setStore("session", "messageCursor", sessionID, response.cursor.next ?? undefined)
batch(() => {
messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])))
setStore("session", "message", sessionID, reconcile(messages))
setStore("session", "messageCursor", sessionID, response.cursor.next ?? undefined)
})
})
},
more(sessionID: string) {
+38
View File
@@ -14,6 +14,44 @@ const session = (viewed: number): SessionInfo => ({
location: { directory: "/project" },
})
test("uses the configured initial window and retains normal cursor page sizes", async () => {
const requests: { limit: string | null; cursor: string | null }[] = []
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const url = new URL((input instanceof Request ? input : new Request(input, init)).url)
const cursor = url.searchParams.get("cursor")
requests.push({ limit: url.searchParams.get("limit"), cursor })
return Response.json({
data: [{ id: cursor ? "msg_1" : "msg_2", type: "user", text: "History", time: { created: cursor ? 1 : 2 } }],
cursor: cursor ? {} : { next: "older" },
})
},
})
const setup = createRoot((dispose) => ({
data: createData({
api: () => api,
directory: "/project",
initialMessageLimit: () => 40,
event: { on: () => () => {}, listen: () => () => {} },
}),
dispose,
}))
try {
await setup.data.session.message.sync("ses_refresh")
await setup.data.session.message.sync("ses_refresh")
expect(requests).toEqual([{ limit: "40", cursor: null }])
await setup.data.session.message.loadMore("ses_refresh")
expect(requests).toEqual([
{ limit: "40", cursor: null },
{ limit: "20", cursor: "older" },
])
expect(setup.data.session.message.list("ses_refresh").map((message) => message.id)).toEqual(["msg_1", "msg_2"])
} finally {
setup.dispose()
}
})
test("revalidates after an event overtakes an active session read", async () => {
let release!: () => void
const gate = new Promise<void>((resolve) => (release = resolve))
+16 -13
View File
@@ -47,7 +47,7 @@ ultimate source of truth.
- [x] `const`, `let`, and accepted `var` declarations.
- [x] Object and array destructuring in declarations, parameters, assignment expressions, and `for...of` bindings.
- [x] Nested patterns, defaults, elisions, and rest elements.
- [x] Assignment to identifiers, unblocked plain-object fields, non-negative integer array indexes, and writable URL
- [x] Assignment to identifiers, plain-object fields, non-negative integer array indexes, and writable URL
fields.
- [x] Direct function declarations are hoisted in program and block statement lists.
- [x] Parameter defaults observe a temporal dead zone for later parameters.
@@ -138,7 +138,10 @@ ultimate source of truth.
- [x] Sequence expressions (the comma operator).
- [x] `await` for CodeMode promises and callable thenables; a plain value passes through unchanged, though every
`await` still defers its continuation one reaction turn.
- [x] `new` for Array, Object, Error types, Date, RegExp, Map, Set, URL, URLSearchParams, and Promise.
- [x] `new` for Array, Object, Error types, Date, RegExp, Map, Set, URL, URLSearchParams, and Promise. `new` on any
other value throws a catchable `TypeError` naming the callee: other built-in functions such as `Number` say
`new` is unsupported and point at the plain call, user-defined functions report the constructor gap below, and
non-callable values are not constructors.
- [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`.
- [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`.
- [x] Bitwise operators: `&`, `|`, `^`, `~`, `<<`, `>>`, and `>>>`.
@@ -210,9 +213,10 @@ ultimate source of truth.
synchronous iterator support for `fromEntries`.
- [x] `Object.keys` over arrays and tool references.
- [x] Object identity is preserved by in-CodeMode Object helpers.
- [x] Prototype traversal and mutation through `__proto__`, `constructor`, and `prototype` are blocked.
- [ ] Legal own data fields named `__proto__`, `constructor`, or `prototype` are rejected at JSON/tool boundaries and
cannot be created, read, or written in CodeMode; tool path segments with those names remain supported.
- [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
and null-prototype results.
@@ -276,23 +280,20 @@ ultimate source of truth.
use their epoch time) and reject opaque runtime references as data errors.
- [x] Unknown static members on global namespaces and on `Number`/`String`/the coercion functions read as `undefined`
for feature detection. Calling any undefined value reports a native-style `TypeError` naming the callee, for
example `Math.sum is not a function.` Blocked members (`constructor`, `__proto__`, ...) still throw,
and unknown `Promise` statics keep their descriptive error.
example `Math.sum is not a function.` Unknown `Promise` statics keep their descriptive error.
- [x] `Math.sumPrecise` over finite collections and custom synchronous iterators/generators, rejecting non-number
elements without coercion.
- [x] Global coercing `isFinite` and `isNaN`; opaque runtime references reject as data errors, like `Number(...)`.
## JSON and console
- [x] `JSON.parse` and `JSON.stringify` for supported data objects; the blocked data-key gap listed above still applies.
- [x] `JSON.parse` and `JSON.stringify` for supported data objects.
- [x] Numeric/string indentation for `JSON.stringify`.
- [x] `JSON.parse` reviver callbacks, including postorder traversal, deletion through `undefined`, and root replacement.
Revivers receive `(key, value)` but no `this` holder because CodeMode functions intentionally have no `this`.
- [x] `JSON.stringify` function and array replacers. Function replacers receive `(key, value)` in preorder, including
the root, but no `this` holder. Array replacers preserve requested property order, deduplicate names, coerce
number primitives, and ignore non-string/non-number entries. Primitive wrapper entries remain unsupported.
- [x] JSON callbacks retain the blocked-key boundary: parsed or stringified data containing `__proto__`, `constructor`,
or `prototype` is rejected before callback traversal.
- [x] Captured `console.log`, `console.info`, `console.debug`, `console.warn`, and `console.error`.
- [x] Captured `console.dir` and `console.table`.
@@ -323,7 +324,7 @@ ultimate source of truth.
- [x] `test`, `exec`, and `toString`.
- [x] Readable `source`, `flags`, `lastIndex`, `hasIndices`, `global`, `ignoreCase`, `multiline`, `sticky`, `unicode`,
`unicodeSets`, and `dotAll`.
- [x] Captures, safe named groups (blocked member names are omitted), match `.index`, and stateful global matching.
- [x] Captures, named groups, match `.index`, and stateful global matching.
- [x] Integration with supported String methods, including function replacers.
- [x] Writable `lastIndex`.
- [x] Match `indices` metadata for the `d` flag, including named groups on `exec`, `match`, and `matchAll` results.
@@ -361,13 +362,15 @@ ultimate source of truth.
or without `new`.
- [x] `AggregateError` with the `(errors, message?)` signature and an own `errors` array, constructed directly or by
an all-rejected `Promise.any`; direct construction accepts custom synchronous iterators and generators.
- [x] Error `name`/`message`, error inheritance through `instanceof`, and plain-data serialization.
- [x] Error `name`/`message`, error inheritance through `instanceof`, and plain-data serialization. Errors have no
`stack`; the diagnostic carries the source location instead.
- [x] `instanceof` for Date, RegExp, Map, Set, URL, URLSearchParams, Array, Object, Promise, and Error types.
- [x] Catchable user throws, runtime failures raised during interpreted evaluation, awaited tool failures, and awaited
tool-call-limit failures; parse/compile failures, cooperative timeout, and output bounding remain outside program
`catch`.
- [x] Source locations on unsupported-syntax diagnostics for JavaScript-shaped input; TypeScript transpilation may
shift them.
shift them. The diagnostic names the rejected node type and attaches a short orientation to the supported
subset; this matrix is the full reference.
- [x] Model-visible host failure messages and underlying causes, including output-validation errors.
- [ ] Distinguish user-thrown failures from interpreter defects and explicit tool refusals from internal tool
failures; preserve those categories in caught errors, promise rejection handlers, and `Promise.allSettled`
+8 -12
View File
@@ -22,10 +22,6 @@ export class ToolRuntimeError extends Error {
}
}
const blockedMemberNames = new Set(["__proto__", "constructor", "prototype"])
export const isBlockedMember = (name: string): boolean => blockedMemberNames.has(name)
/**
* Brings a host-produced runtime value into the program: runtime values pass through, their host
* counterparts (Date, RegExp, Map, Set, URL, URLSearchParams) are wrapped, and objects become
@@ -118,10 +114,7 @@ const copy = (value: unknown, label: string, mode: Mode, depth: number, seen: Se
if (mode === "program") {
for (const [key, item] of Object.entries(value)) {
if (Object.hasOwn(copied, key)) continue
if (isBlockedMember(key)) {
throw new ToolRuntimeError("InvalidDataValue", `${label} contains blocked property '${key}'.`)
}
Reflect.set(copied, key, copy(item, label, mode, depth + 1, seen))
define(copied, key, copy(item, label, mode, depth + 1, seen))
}
}
seen.delete(value)
@@ -135,13 +128,16 @@ const copy = (value: unknown, label: string, mode: Mode, depth: number, seen: Se
const copied: SafeObject = plain ? (Object.create(null) as SafeObject) : {}
for (const [key, item] of Object.entries(value)) {
if (isBlockedMember(key)) {
throw new ToolRuntimeError("InvalidDataValue", `${label} contains blocked property '${key}'.`)
}
const next = copy(item, label, mode, depth + 1, seen)
if (next === undefined && mode === "json") continue
copied[key] = next
define(copied, key, next)
}
seen.delete(value)
return copied
}
// Own data property regardless of the target's prototype, so a "__proto__" key on a host object or
// array never reaches the Object.prototype setter.
const define = (target: object, key: string, value: unknown): void => {
Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true })
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { isBlockedMember, type SafeObject, toProgram } from "../data.js"
import { type SafeObject, toProgram } from "../data.js"
import { dateSetterArgumentCount, invokeDateMethod } from "../stdlib/date.js"
import { invokeNumberMethod } from "../stdlib/number.js"
import { invokeRegExpMethod, matchToValue, toHostRegex } from "../stdlib/regexp.js"
@@ -280,7 +280,7 @@ const invokeStringReplacer = <R>(
if (hasGroups) {
const safeGroups: SafeObject = Object.create(null) as SafeObject
for (const [key, group] of Object.entries(groups)) {
if (!isBlockedMember(key)) safeGroups[key] = group
safeGroups[key] = group
}
callbackArgs[callbackArgs.length - 1] = safeGroups
}
+4 -3
View File
@@ -88,9 +88,6 @@ export class GeneratorReturn {
export const OptionalShortCircuit: unique symbol = Symbol("codemode.optional-short-circuit")
export const supportedSyntaxMessage =
"Supported orchestration syntax: tools.* calls (they return promises - resolve them with await), data literals, destructuring, optional chaining, template literals, conditionals, switch, loops (incl. for...of and for...in over object/array/tools keys), arrow functions, spread, try/catch, array methods (map/filter/find/findIndex/some/every/reduce/flatMap/forEach/sort/slice/concat/indexOf/lastIndexOf/at/flat/reverse/includes/join), string methods (incl. match/matchAll/replace/split with regular expressions), Date/RegExp/Map/Set/URL/URLSearchParams, URI encoding helpers, Object/Math/JSON helpers, captured console.log/warn/error/dir/table, Promise.all/allSettled/race/any/resolve/reject over arrays mixing promises and plain values for parallel tool calls, promise chaining with .then/.catch/.finally, and new Promise((resolve, reject) => ...) construction."
export class InterpreterRuntimeError extends Error {
readonly node?: AstNode
errorName = "Error"
@@ -112,6 +109,10 @@ export class InterpreterRuntimeError extends Error {
}
}
// Orient the agent rather than enumerate JavaScript; interpreter-support.md is the full matrix.
export const supportedSyntaxMessage =
"This is a restricted JavaScript-like language. Supported: plain and async functions, data literals, destructuring, standard control flow, await and Promise, and built-ins such as Array, Object, Math, JSON, Date, RegExp, Map, Set, and URL. Unsupported: classes, this, getters/setters, tagged templates, BigInt, and custom Symbols. Use plain functions and data objects instead."
export const unsupportedSyntax = (kind: string, node: AstNode): InterpreterRuntimeError =>
new InterpreterRuntimeError(
`Syntax '${kind}' is not supported. ${supportedSyntaxMessage}`,
@@ -77,6 +77,23 @@ export const rejectCircularInsertion = (
}
}
export const describeValue = (value: unknown): string => {
if (value === null) return "null"
if (Array.isArray(value)) return "an array"
if (value instanceof Values.Promise) return "an un-awaited Promise"
if (value instanceof ToolReference) return "a tool reference"
if (value instanceof Values.Date) return "a Date"
if (value instanceof Values.RegExp) return "a RegExp"
if (value instanceof Values.Map) return "a Map"
if (value instanceof Values.Set) return "a Set"
if (value instanceof Values.URL) return "a URL"
if (value instanceof Values.URLSearchParams) return "a URLSearchParams"
if (value instanceof CodeModeGenerator) return "a generator"
if (isRuntimeReference(value)) return "a function"
if (typeof value === "object") return "a data object"
return `a ${typeof value}`
}
export const typeofValue = (value: unknown): string => {
if (
value instanceof HostFunction ||
+32 -29
View File
@@ -41,7 +41,7 @@ import type {
YieldExpression,
} from "acorn"
import { Cause, Deferred, Effect, Exit } from "effect"
import { isBlockedMember, ToolRuntimeError, type SafeObject, toProgram } from "../data.js"
import { ToolRuntimeError, type SafeObject, toProgram } from "../data.js"
import { ToolReference } from "../tool-runtime.js"
import {
type AstNode,
@@ -71,7 +71,13 @@ import { HostFunction, HostNamespace } from "./host.js"
import { invokeIntrinsic } from "./methods.js"
import { preserveConsumerError, type Runner } from "./runner.js"
import { invokePromiseInstanceMethod, PromiseRuntime, resolvePromise, resolvePromiseValue } from "./promises.js"
import { containsOpaqueReference, isRuntimeReference, rejectCircularInsertion, typeofValue } from "./references.js"
import {
containsOpaqueReference,
describeValue,
isRuntimeReference,
rejectCircularInsertion,
typeofValue,
} from "./references.js"
import { ScopeStack } from "./scope.js"
import { arrayMethods, mapMethods, setMethods } from "../stdlib/collections.js"
import { dateMethods } from "../stdlib/date.js"
@@ -1025,7 +1031,7 @@ class Frame<R> {
if (pattern.type === "ObjectPattern") {
if (value === null || typeof value !== "object" || isRuntimeReference(value)) {
throw new InterpreterRuntimeError(
"Object destructuring requires a data object or array value.",
`Object destructuring requires a data object or array value, received ${describeValue(value)}.`,
pattern,
"InvalidDataValue",
)
@@ -1036,7 +1042,7 @@ class Frame<R> {
if (property.type === "RestElement") {
const rest: SafeObject = Object.create(null) as SafeObject
for (const [key, item] of Object.entries(value as SafeObject)) {
if (!consumed.has(key) && !isBlockedMember(key)) rest[key] = item
if (!consumed.has(key)) rest[key] = item
}
copyIteratorSymbols(value, rest, consumed)
yield* self.declarePattern(property.argument, rest, mutable, property, initialize)
@@ -1044,9 +1050,6 @@ class Frame<R> {
}
const key = yield* self.destructuringPropertyKey(property)
if (isBlockedMember(String(key))) {
throw new InterpreterRuntimeError(`Property '${String(key)}' is not available.`, property)
}
consumed.add(typeof key === "symbol" ? key : String(key))
yield* self.declarePattern(
property.value,
@@ -1091,7 +1094,7 @@ class Frame<R> {
if (pattern.type === "ObjectPattern") {
if (value === null || typeof value !== "object" || isRuntimeReference(value)) {
throw new InterpreterRuntimeError(
"Object destructuring requires a data object or array value.",
`Object destructuring requires a data object or array value, received ${describeValue(value)}.`,
pattern,
"InvalidDataValue",
)
@@ -1103,16 +1106,13 @@ class Frame<R> {
if (property.type === "RestElement") {
const rest: SafeObject = Object.create(null) as SafeObject
for (const [key, item] of Object.entries(source)) {
if (!consumed.has(key) && !isBlockedMember(key)) rest[key] = item
if (!consumed.has(key)) rest[key] = item
}
copyIteratorSymbols(source, rest, consumed)
yield* self.assignPattern(property.argument, rest, property)
continue
}
const key = yield* self.destructuringPropertyKey(property)
if (isBlockedMember(String(key))) {
throw new InterpreterRuntimeError(`Property '${String(key)}' is not available.`, property)
}
consumed.add(typeof key === "symbol" ? key : String(key))
yield* self.assignPattern(property.value, self.destructuringPropertyValue(source, key), property)
}
@@ -1264,7 +1264,19 @@ class Frame<R> {
const callee = yield* self.evaluateExpression(node.callee)
// Globals are built with this interpreter's R; `instanceof` cannot recover the type argument.
const construct = callee instanceof HostFunction ? (callee as HostFunction<R>).construct : undefined
if (construct === undefined) throw unsupportedSyntax("NewExpression", node)
if (construct === undefined) {
// `new` itself is supported, so a non-constructible callee is a TypeError like JS rather than
// unsupported syntax. Built-ins like Number are real constructors in JS, so do not claim
// otherwise; say `new` is unsupported for them and point at the plain call.
const name = calleeDescription(node.callee)
const message =
callee instanceof CodeModeFunction
? `${name} cannot be constructed: user-defined constructors and classes are not supported. Call it as a function that returns a plain object instead.`
: callee instanceof HostFunction
? `new ${name}(...) is not supported; call ${name}(...) without new instead.`
: `${name} is not a constructor.`
throw new InterpreterRuntimeError(message, node).as("TypeError")
}
const args = yield* self.evaluateCallArguments(node.arguments)
return yield* construct(args, node)
})
@@ -1884,12 +1896,13 @@ class Frame<R> {
const spread = yield* self.evaluateExpression(property.argument)
if (spread === null || spread === undefined || Values.isValue(spread)) continue
if (typeof spread !== "object" || Array.isArray(spread) || isRuntimeReference(spread)) {
throw new InterpreterRuntimeError("Object spread requires a data object.", property, "InvalidDataValue")
}
for (const [key, value] of Object.entries(spread)) {
if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available.`, property)
objectValue[key] = value
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
}
@@ -1912,9 +1925,6 @@ class Frame<R> {
throw new InterpreterRuntimeError("Unsupported object property key shape.", keyNode)
}
if (isBlockedMember(String(key))) {
throw new InterpreterRuntimeError(`Property '${String(key)}' is not available.`, keyNode)
}
Reflect.set(objectValue, key, yield* self.evaluateExpression(property.value))
}
@@ -2029,9 +2039,6 @@ class Frame<R> {
}
if (objectValue instanceof HostFunction || objectValue instanceof HostNamespace) {
if (typeof key === "string" && isBlockedMember(key)) {
throw new InterpreterRuntimeError(`${objectValue.name}.${key} is not available.`, propertyNode)
}
// Unknown static members read as undefined so feature detection works like native JS.
return new ComputedValue(objectValue.member(key, propertyNode))
}
@@ -2114,7 +2121,7 @@ class Frame<R> {
if (isRuntimeReference(objectValue)) {
throw new InterpreterRuntimeError(
"Runtime references are opaque and do not expose properties.",
`Cannot read properties of ${describeValue(objectValue)}; only data values expose properties.`,
objectNode,
"InvalidDataValue",
)
@@ -2124,10 +2131,6 @@ class Frame<R> {
throw new InterpreterRuntimeError("Cannot access a property on a non-object value.", objectNode)
}
if (typeof key === "string" && isBlockedMember(key)) {
throw new InterpreterRuntimeError(`Property '${key}' is not available.`, propertyNode)
}
if (Array.isArray(objectValue)) {
if (operation === "delete") return { target: objectValue, key }
const index = typeof key === "symbol" ? undefined : parseArrayIndex(key)
+3 -5
View File
@@ -1,6 +1,5 @@
import { fromSchemaOpenApi3_0, fromSchemaOpenApi3_1 } from "effect/JsonSchema"
import type { JsonSchema } from "../tool.js"
import { isBlockedMember } from "../data.js"
import type {
Body,
Document,
@@ -468,8 +467,7 @@ export const operationInput = (
ok: true,
value: {
fields: fields.map((field) => {
const visibleName = isBlockedMember(field.name) ? `${field.name}_2` : field.name
const base = conflicts.has(field.name) ? `${field.location}_${visibleName}` : visibleName
const base = conflicts.has(field.name) ? `${field.location}_${field.name}` : field.name
const next = (index: number): string => {
const candidate = index === 1 ? base : `${base}_${index}`
return used.has(candidate) ? next(index + 1) : candidate
@@ -567,12 +565,12 @@ export const operationOutput = (
}
const sanitizeOperationSegment = (raw: string): string => {
const base =
return (
raw
.replaceAll(/[^A-Za-z0-9_$]+/g, "_")
.replace(/^_+|_+$/g, "")
.replace(/^([0-9])/, "_$1") || "operation"
return isBlockedMember(base) ? `${base}_2` : base
)
}
const fallbackOperationId = (method: string, path: string): string =>
+2 -9
View File
@@ -1,8 +1,8 @@
import { Effect } from "effect"
import { HostFunction, sync, syncCall } from "../interpreter/host.js"
import { type AstNode, CodeModeGenerator, InterpreterRuntimeError } from "../interpreter/model.js"
import { describeValue } from "../interpreter/references.js"
import { applyCollectionCallback, preserveConsumerError, type Runner } from "../interpreter/runner.js"
import { Values } from "../values.js"
const constructArray = (args: Array<unknown>, node: AstNode): Array<unknown> => {
if (args.length !== 1) return [...args]
@@ -16,13 +16,6 @@ const constructArray = (args: Array<unknown>, node: AstNode): Array<unknown> =>
}
const arrayLikeSource = (source: unknown, node: AstNode): { readonly length: number; readonly source: object } => {
if (source instanceof Values.Promise) {
throw new InterpreterRuntimeError(
"Array.from received an un-awaited Promise; await it before creating the array.",
node,
"InvalidDataValue",
)
}
if (
source !== null &&
typeof source === "object" &&
@@ -35,7 +28,7 @@ const arrayLikeSource = (source: unknown, node: AstNode): { readonly length: num
return { length: normalized, source }
}
throw new InterpreterRuntimeError(
"Array.from expects an array, string, Map, Set, or array-like value.",
`Array.from expects an array, string, Map, Set, or array-like value, received ${describeValue(source)}.`,
node,
"InvalidDataValue",
)
+7 -9
View File
@@ -1,8 +1,8 @@
import { Effect } from "effect"
import { isBlockedMember, type SafeObject } from "../data.js"
import type { SafeObject } from "../data.js"
import { HostFunction, requiresNew } from "../interpreter/host.js"
import { type AstNode, InterpreterRuntimeError, isRecord } from "../interpreter/model.js"
import { isRuntimeReference } from "../interpreter/references.js"
import { describeValue, isRuntimeReference } from "../interpreter/references.js"
import { applyCollectionCallback, preserveConsumerError, type Runner, toPrimitive } from "../interpreter/runner.js"
import { Values } from "../values.js"
import { coerceToString } from "./value.js"
@@ -73,7 +73,11 @@ const coerceGroupByPropertyKey = <R>(
): Effect.Effect<string, unknown, R> => {
if (value instanceof Values.Promise) return Effect.succeed("[object Promise]")
if (!Values.isValue(value) && isRuntimeReference(value)) {
throw new InterpreterRuntimeError("Object.groupBy callback must return a data value.", node, "InvalidDataValue")
throw new InterpreterRuntimeError(
`Object.groupBy callback must return a data value, received ${describeValue(value)}.`,
node,
"InvalidDataValue",
)
}
return Effect.map(toPrimitive(runner, value, "string", node), coerceToString)
}
@@ -120,12 +124,6 @@ export const groupBy = <R>(runner: Runner<R>, namespace: "Map" | "Object") =>
cursor,
Effect.flatMap(apply([item, index]), (value) => coerceGroupByPropertyKey(runner, value, node)),
)
if (isBlockedMember(key)) {
return yield* preserveConsumerError(
cursor,
Effect.fail(new InterpreterRuntimeError(`Property '${key}' is not available.`, node)),
)
}
const group = result[key]
if (group === undefined) result[key] = [item]
else (group as Array<unknown>).push(item)
-9
View File
@@ -6,15 +6,6 @@ import { typeofValue } from "../interpreter/references.js"
import { fromData, type SafeObject, toData, toProgram } from "../data.js"
import { Values } from "../values.js"
export const invokeJsonMethod = <R>(
runner: Runner<R>,
name: "parse" | "stringify",
args: Array<unknown>,
node: AstNode,
): Effect.Effect<unknown, unknown, R> => {
return name === "parse" ? parse(runner, args, node) : stringify(runner, args, node)
}
export const jsonGlobal = <R>(runner: Runner<R>) =>
new HostNamespace("JSON", {
parse: new HostFunction<R>({ name: "JSON.parse", call: (args, node) => parse(runner, args, node) }),
+11 -15
View File
@@ -1,8 +1,13 @@
import { Effect } from "effect"
import { isBlockedMember, toProgram } from "../data.js"
import { toProgram } from "../data.js"
import { HostFunction, sync, syncCall } from "../interpreter/host.js"
import { type AstNode, AsyncIteratorSymbol, InterpreterRuntimeError, IteratorSymbol } from "../interpreter/model.js"
import { containsOpaqueReference, rejectCircularInsertion, typeofValue } from "../interpreter/references.js"
import {
containsOpaqueReference,
describeValue,
rejectCircularInsertion,
typeofValue,
} from "../interpreter/references.js"
import { preserveConsumerError, type Runner } from "../interpreter/runner.js"
import { ToolReference } from "../tool-runtime.js"
import { Values } from "../values.js"
@@ -12,20 +17,14 @@ import { coerceToString } from "./value.js"
const requireObject = (name: string, input: unknown, node: AstNode): Record<string, unknown> => {
if (Array.isArray(input)) return input as unknown as Record<string, unknown>
if (Values.isValue(input)) return {}
if (input instanceof Values.Promise) {
const prototype = input === null || typeof input !== "object" ? undefined : Object.getPrototypeOf(input)
if (prototype !== null && prototype !== Object.prototype) {
throw new InterpreterRuntimeError(
`Object.${name} 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 (input === null || typeof input !== "object") {
throw new InterpreterRuntimeError(`Object.${name} expects a data object or array.`, node, "InvalidDataValue")
}
const prototype = Object.getPrototypeOf(input)
if (prototype !== null && prototype !== Object.prototype) {
throw new InterpreterRuntimeError(`Object.${name} expects a data object or array.`, node, "InvalidDataValue")
}
return input as Record<string, unknown>
}
@@ -37,8 +36,6 @@ export const objectAssign = (args: Array<unknown>, node: AstNode): unknown => {
const out = target as Record<string, unknown>
const seen = new Set<object>()
const guardedSet = (key: PropertyKey, item: unknown): void => {
if (typeof key === "string" && isBlockedMember(key))
throw new InterpreterRuntimeError(`Property '${key}' is not available.`, node)
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(
@@ -96,7 +93,6 @@ const objectFromEntries = <R>(
toProgram(entry[0], "Object.fromEntries key")
toProgram(entry[1], "Object.fromEntries value")
const key = coerceToString(entry[0])
if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available.`, node)
out[key] = entry[1]
}),
)
@@ -106,7 +102,7 @@ const objectFromEntries = <R>(
const constructObject = (args: Array<unknown>, node: AstNode): unknown => {
const first = args[0]
if (first === null || first === undefined) return {}
if (first === null || first === undefined) return Object.create(null)
if (typeof first === "object") return first
throw new InterpreterRuntimeError(
`Object(${typeof first}) wrapper objects are not supported; use the primitive value directly.`,
+3 -3
View File
@@ -1,6 +1,6 @@
import { sync, syncCall } from "../interpreter/host.js"
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import { isBlockedMember, type SafeObject } from "../data.js"
import type { SafeObject } from "../data.js"
import { Values } from "../values.js"
import { coerceToNumber, coerceToString } from "./value.js"
@@ -62,7 +62,7 @@ export const matchToValue = (match: RegExpMatchArray): Array<unknown> => {
if (match.groups) {
const groups: SafeObject = Object.create(null) as SafeObject
for (const [key, group] of Object.entries(match.groups)) {
if (!isBlockedMember(key)) groups[key] = group
groups[key] = group
}
result.groups = groups
}
@@ -148,7 +148,7 @@ const indicesToValue = (indices: RegExpIndicesArray): IndicesValue => {
if (indices.groups) {
const groups: SafeObject = Object.create(null) as SafeObject
for (const [key, range] of Object.entries(indices.groups)) {
if (!isBlockedMember(key)) groups[key] = range === undefined ? undefined : [...range]
groups[key] = range === undefined ? undefined : [...range]
}
result.groups = groups
return result
+16 -3
View File
@@ -85,9 +85,22 @@ describe("Object.keys over arrays", () => {
expect(await value(`return Object.keys({ a: 1, b: 2 })`)).toEqual(["a", "b"])
})
test("non-object inputs still fail clearly", async () => {
const failure = await error(`return Object.keys("nope")`)
expect(failure.message).toContain("Object.keys expects a data object or array")
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.",
)
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.")
expect((await error(`return (() => 1).x`)).message).toContain("Cannot read properties of a function")
})
})
@@ -29,7 +29,6 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { CodeMode } from "../src/index.js"
import { invokeJsonMethod } from "../src/stdlib/json.js"
const value = async (code: string) => {
const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} }))
@@ -218,27 +217,19 @@ describe("CodeMode JSON callback boundaries", () => {
expect(result).toMatchObject({ ok: false, error: { kind: "UnsupportedSyntax" } })
})
test("blocked parse keys are rejected before reviver traversal", async () => {
test("prototype-named keys parse as own data and reach the reviver", async () => {
expect(
await value(
`try { JSON.parse('{"__proto__":1}', (key, item) => item) } catch (error) { return true } return false`,
),
).toBe(true)
await value(`
const seen = []
const parsed = JSON.parse('{"__proto__":{"polluted":1},"constructor":2}', (key, item) => { seen.push(key); return item })
return [seen, parsed.__proto__.polluted, parsed.constructor, ({}).polluted, Object.keys(parsed)]
`),
).toEqual([["polluted", "__proto__", "constructor", ""], 1, 2, null, ["__proto__", "constructor"]])
})
test("JSON.stringify directly rejects blocked input keys", () => {
expect(() =>
invokeJsonMethod(
{
invokeFunction: () => Effect.die("unused"),
invokeCallable: () => Effect.die("unused"),
settlePromise: () => Effect.die("unused"),
syncIterator: () => Effect.die("unused"),
},
"stringify",
[Object.fromEntries([["constructor", 1]])],
{ type: "CallExpression", start: 0, end: 0 },
),
).toThrow("blocked property 'constructor'")
test("JSON.stringify serializes prototype-named own keys", async () => {
expect(await value(`return JSON.stringify({ constructor: 1, __proto__: 2 })`)).toBe(
'{"constructor":1,"__proto__":2}',
)
})
})
@@ -0,0 +1,80 @@
import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { CodeMode, Tool } from "../src/index.js"
// `new` is supported syntax; only the callee decides whether construction succeeds. A callee without
// construction support is a TypeError naming it, like JS, rather than an unsupported-syntax diagnostic
// that would suggest `new` itself is unavailable.
const tools = {
echo: Tool.make({
description: "Echo",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () => Effect.succeed({}),
}),
}
const run = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools }))
const value = async (code: string) => {
const result = await run(code)
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
return result.value
}
const error = async (code: string) => {
const result = await run(code)
if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`)
return result.error
}
describe("new on a non-constructible callee", () => {
test("built-in functions without construction point at the plain call", async () => {
// Number is a real constructor in JS, so the message must not claim otherwise.
const failure = await error(`return new Number(42)`)
expect(failure.kind).toBe("ExecutionFailure")
expect(failure.message).toStartWith("new Number(...) is not supported; call Number(...) without new instead.")
expect(failure.suggestions).toBeUndefined()
expect((await error(`return new String("a")`)).message).toStartWith("new String(...) is not supported")
expect((await error(`return new Math.abs(1)`)).message).toStartWith(
"new Math.abs(...) is not supported; call Math.abs(...) without new instead.",
)
})
test("non-callable values are not constructors", async () => {
expect((await error(`return new tools.echo()`)).message).toStartWith("tools.echo is not a constructor.")
expect((await error(`return new (1)()`)).message).toStartWith("The called value is not a constructor.")
expect((await error(`const Date = 5; return new Date()`)).message).toStartWith("Date is not a constructor.")
})
test("user-defined functions explain the documented gap", async () => {
const failure = await error(`function Point(x) { return { x } }; return new Point(1)`)
expect(failure.message).toStartWith(
"Point cannot be constructed: user-defined constructors and classes are not supported. Call it as a function that returns a plain object instead.",
)
expect((await error(`const make = () => ({}); return new make()`)).message).toStartWith(
"make cannot be constructed",
)
})
test("the failure is a catchable TypeError", async () => {
expect(
await value(`
try { new Number(1) } catch (error) { return [error.name, error instanceof TypeError] }
`),
).toEqual(["TypeError", true])
})
test("an undeclared callee still fails as an unknown identifier", async () => {
expect((await error(`return new Function("return 1")`)).message).toContain("Function")
expect((await error(`return new Function("return 1")`)).message).not.toContain("not a constructor")
})
test("classes remain unsupported syntax", async () => {
const failure = await error(`class A {}; return new A()`)
expect(failure.kind).toBe("UnsupportedSyntax")
expect(failure.message).toStartWith(
"Syntax 'ClassDeclaration' is not supported. This is a restricted JavaScript-like language. Supported: ",
)
expect(failure.message).toContain(
"Unsupported: classes, this, getters/setters, tagged templates, BigInt, and custom Symbols.",
)
})
})
+1 -1
View File
@@ -1095,7 +1095,7 @@ describe("OpenAPI.fromSpec", () => {
tags: ["x", "y"],
filter: { state: "open", page: 2 },
nullable: null,
constructor_2: "safe",
constructor: "safe",
meta: { a: "b", c: "d" },
})
.pipe(Effect.provide(client.layer)),
+12 -8
View File
@@ -265,9 +265,11 @@ describe("property deletion", () => {
expect((await error(`return delete tools.example`)).kind).toBe("InvalidDataValue")
})
test("keeps blocked property names unavailable", async () => {
expect((await error(`const object = {}; return delete object.__proto__`)).kind).toBe("ExecutionFailure")
expect((await error(`const values = []; return delete values["constructor"]`)).kind).toBe("ExecutionFailure")
test("prototype-named keys delete like any own data key", async () => {
expect(
await value(`const object = { __proto__: 1, a: 2 }; delete object.__proto__; return Object.keys(object)`),
).toEqual(["a"])
expect(await value(`const values = [1]; delete values["constructor"]; return values`)).toEqual([1])
})
})
@@ -906,10 +908,12 @@ describe("coercion parity: unknown static members read as undefined", () => {
)
})
test("blocked members still throw instead of reading as undefined", async () => {
const err = await error(`return Math.constructor`)
expect(err.message).toContain("not available")
const coercionErr = await error(`return Number.constructor`)
expect(coercionErr.message).toContain("Number.constructor is not available")
test("prototype-named members on globals read as undefined like other unknown statics", async () => {
expect(await value(`return [Math.constructor, Number.constructor, Object.prototype, Array.__proto__]`)).toEqual([
null,
null,
null,
null,
])
})
})
@@ -126,7 +126,7 @@ describe("RegExp Test262 parity", () => {
const match = /(?<a>a)(b)?/d.exec("a")
const stringMatch = "a".match(/a/d)
const all = "a a".matchAll(/a/dg)
const blocked = /(?<constructor>a)(?<safe>b)/d.exec("ab")
const named = /(?<constructor>a)(?<safe>b)/d.exec("ab")
return [
/./.hasIndices,
/./d.hasIndices,
@@ -138,10 +138,10 @@ describe("RegExp Test262 parity", () => {
stringMatch.indices[0],
all[0].indices[0],
all[1].indices[0],
Object.keys(blocked.indices.groups),
Object.keys(named.indices.groups),
]
`),
).toEqual([false, true, true, [0, 1], [0, 1], null, [0, 1], [0, 1], [0, 1], [2, 3], ["safe"]])
).toEqual([false, true, true, [0, 1], [0, 1], null, [0, 1], [0, 1], [0, 1], [2, 3], ["constructor", "safe"]])
})
test("match indices preserve captures, Unicode offsets, and groups properties", async () => {
@@ -177,13 +177,13 @@ describe("RegExp Test262 parity", () => {
])
})
test("match and matchAll preserve named, unmatched, and blocked index groups", async () => {
test("match and matchAll preserve named, unmatched, and prototype-named index groups", async () => {
expect(
await value(`
const matched = "a".match(/(?<a>a)|(?<x>x)/d).indices.groups
const all = "a x".matchAll(/(?<a>a)|(?<x>x)/dg)
const blockedMatch = "ab".match(/(?<constructor>a)(?<safe>b)/d).indices.groups
const blockedAll = "ab".matchAll(/(?<constructor>a)(?<safe>b)/dg)[0].indices.groups
const namedMatch = "ab".match(/(?<constructor>a)(?<safe>b)/d).indices.groups
const namedAll = "ab".matchAll(/(?<constructor>a)(?<safe>b)/dg)[0].indices.groups
return [
matched.a,
matched.x,
@@ -191,11 +191,11 @@ describe("RegExp Test262 parity", () => {
all[0].indices.groups.x,
all[1].indices.groups.a,
all[1].indices.groups.x,
Object.keys(blockedMatch),
Object.keys(blockedAll),
Object.keys(namedMatch),
Object.keys(namedAll),
]
`),
).toEqual([[0, 1], null, [0, 1], null, null, [2, 3], ["safe"], ["safe"]])
).toEqual([[0, 1], null, [0, 1], null, null, [2, 3], ["constructor", "safe"], ["constructor", "safe"]])
})
test("v flag exposes unicodeSets and remains exclusive with u", async () => {
+5 -3
View File
@@ -55,7 +55,9 @@ describe("Number and Math", () => {
})
test("Number valueOf does not enable boxed numbers", async () => {
expect((await error(`return new Number(42)`)).kind).toBe("UnsupportedSyntax")
const failure = await error(`return new Number(42)`)
expect(failure.kind).toBe("ExecutionFailure")
expect(failure.message).toContain("new Number(...) is not supported; call Number(...) without new instead.")
})
})
@@ -725,9 +727,9 @@ describe("stdlib integration", () => {
expect(await value(`const make = (C) => new C([["a", 1]]); return make(Map).get("a")`)).toBe(1)
expect(await value(`const t = { M: Map }; return new t.M() instanceof Map`)).toBe(true)
const shadowed = await error(`const Date = 5; return new Date()`)
expect(shadowed.kind).toBe("UnsupportedSyntax")
expect(shadowed.message).toStartWith("Date is not a constructor.")
const fn = await error(`const f = () => 1; return new f()`)
expect(fn.kind).toBe("UnsupportedSyntax")
expect(fn.message).toStartWith("f cannot be constructed")
})
test("Object.is uses SameValue semantics", async () => {
+24 -3
View File
@@ -176,10 +176,31 @@ describe("blocked member names on tool paths", () => {
expect(await value(poisoned, `return await tools.ns.real({})`)).toBe("real")
})
test("blocked member access on data values stays blocked", async () => {
const diagnostic = await failure(runtime, `const x = {}; return x.constructor`)
expect(diagnostic.message).toContain("constructor")
test("prototype machinery is unreachable through data values", async () => {
expect(
await value(
runtime,
`
const object = {}
const array = []
object.__proto__ = { polluted: true }
return [
object.constructor, array.constructor, "".constructor, Math.constructor,
object.__proto__.polluted, ({}).polluted, array.__proto__, Object().__proto__, new Object().constructor,
typeof ({}).constructor, typeof [].__proto__,
]
`,
),
).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("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")
expect(Object.keys(Object.prototype)).toEqual([])
expect(Object.keys(Array.prototype)).toEqual([])
})
})
+8 -1
View File
@@ -210,6 +210,10 @@ function mapBedrockRequest(input: MapInput): Pick<Mapping, "headers" | "body"> {
const reasoning = isRecord(settings.reasoningConfig) ? settings.reasoningConfig : undefined
const anthropic = input.modelID.includes("anthropic")
const openai = input.modelID.includes("openai.")
// Converse passes OpenAI fields through verbatim. gpt-oss (Harmony) takes the
// flat chat-completions `reasoning_effort`; GPT-5.6+ reject it and take the
// Responses-style `reasoning.effort` instead.
const harmony = input.modelID.includes("openai.gpt-oss")
const effort = typeof reasoning?.maxReasoningEffort === "string" ? reasoning.maxReasoningEffort : undefined
const type = typeof reasoning?.type === "string" ? reasoning.type : undefined
const budget = typeof reasoning?.budgetTokens === "number" ? reasoning.budgetTokens : undefined
@@ -236,7 +240,10 @@ function mapBedrockRequest(input: MapInput): Pick<Mapping, "headers" | "body"> {
},
}
: {}),
...(!anthropic && openai && effort !== undefined ? { reasoning_effort: effort } : {}),
...(!anthropic && openai && harmony && effort !== undefined ? { reasoning_effort: effort } : {}),
...(!anthropic && openai && !harmony && effort !== undefined
? { reasoning: { ...(isRecord(additional.reasoning) ? additional.reasoning : {}), effort } }
: {}),
...(!anthropic && !openai && effort !== undefined
? {
reasoningConfig: {
+1
View File
@@ -77,6 +77,7 @@ const layer = Layer.effect(
...(provider.canonical === undefined ? {} : { canonical: provider.canonical }),
package: model.package ?? provider.package,
compaction: model.compaction ?? provider.compaction,
websocket: model.websocket ?? provider.websocket,
settings: Provider.mergeOverlay(provider.settings, model.settings),
headers: Provider.mergeHeaders(provider.headers, model.headers),
body: Provider.mergeOverlay(provider.body, model.body),
+10 -3
View File
@@ -184,6 +184,15 @@ function decode(file: { directory: string; filepath: string; primary: boolean },
.replace(/\.md$/, "")
const body = markdown.content.trim()
const legacy = Object.keys(markdown.data).some((key) => !agentKeys.has(key))
// Join legacy model + variant without sending native request/permissions through migration.
// Embedded and structured native selections, and a variant without a model, stay unchanged.
const data =
typeof markdown.data.model === "string" &&
!markdown.data.model.includes("#") &&
typeof markdown.data.variant === "string" &&
/^[^#]+$/.test(markdown.data.variant)
? { ...markdown.data, model: `${markdown.data.model}#${markdown.data.variant}` }
: markdown.data
const agent = legacy
? Option.getOrUndefined(
Option.map(
@@ -191,9 +200,7 @@ function decode(file: { directory: string; filepath: string; primary: boolean },
ConfigMigrateV1.migrateAgent,
),
)
: Option.getOrUndefined(
decodeAgent({ ...markdown.data, system: body }, { errors: "all", propertyOrder: "original" }),
)
: Option.getOrUndefined(decodeAgent({ ...data, system: body }, { errors: "all", propertyOrder: "original" }))
if (!agent) return
const info = Option.getOrUndefined(
decodeConfig({
@@ -58,6 +58,7 @@ export const Plugin = define({
if (item.name !== undefined) provider.name = item.name
if (item.package !== undefined) provider.package = item.package
if (item.compaction !== undefined) provider.compaction = { ...item.compaction }
if (item.websocket !== undefined) provider.websocket = item.websocket
if (item.settings !== undefined) provider.settings = Provider.mergeOverlay(provider.settings, item.settings)
if (item.headers !== undefined) provider.headers = Provider.mergeHeaders(provider.headers, item.headers)
if (item.body !== undefined) provider.body = Provider.mergeOverlay(provider.body, item.body)
@@ -78,6 +79,7 @@ export const Plugin = define({
model.compatibility = { ...model.compatibility, ...config.compatibility }
if (config.package !== undefined) model.package = config.package
if (config.compaction !== undefined) model.compaction = { ...config.compaction }
if (config.websocket !== undefined) model.websocket = config.websocket
if (config.settings !== undefined) model.settings = Provider.mergeOverlay(model.settings, config.settings)
if (config.headers !== undefined) model.headers = Provider.mergeHeaders(model.headers, config.headers)
if (config.body !== undefined) model.body = Provider.mergeOverlay(model.body, config.body)
+3
View File
@@ -85,6 +85,8 @@ export interface Resolved {
readonly limit: Info["limit"]
/** Model policy overrides the provider policy; omitted means local compaction. */
readonly compaction?: Info["compaction"]
/** Whether the session WebSocket may carry this model's requests when the route supports it. */
readonly websocket: boolean
}
export interface Interface {
@@ -321,6 +323,7 @@ export const layer = Layer.effect(
cost: selected.cost,
limit: selected.limit,
compaction: selected.compaction,
websocket: selected.websocket ?? false,
}
})
return Service.of({
+23
View File
@@ -5,6 +5,24 @@ import { Effect, Stream } from "effect"
import { Bus } from "../bus.js"
import { ModelsDev } from "../models-dev.js"
// These catalog entries require inference profiles on Bedrock Runtime.
// Opus/Sonnet 4.6 support in-region calls in eu-west-2 and must remain available.
const BEDROCK_PROFILE_ONLY_IDS = [
"amazon.nova-2-lite-v1:0",
"anthropic.claude-fable-5",
"anthropic.claude-fable-5-1",
"anthropic.claude-haiku-4-5-20251001-v1:0",
"anthropic.claude-opus-4-1-20250805-v1:0",
"anthropic.claude-opus-4-5-20251101-v1:0",
"anthropic.claude-opus-4-7",
"anthropic.claude-opus-4-8",
"anthropic.claude-opus-5",
"anthropic.claude-sonnet-4-5-20250929-v1:0",
"anthropic.claude-sonnet-5",
"deepseek.r1-v1:0",
"mistral.pixtral-large-2502-v1:0",
]
export const ModelsDevPlugin = define({
id: "opencode.models.dev",
effect: Effect.fn(function* (ctx) {
@@ -39,6 +57,11 @@ export const ModelsDevPlugin = define({
})
for (const model of provider.models) {
if (model.status === "deprecated") continue
if (
provider.info.id === Provider.ID.amazonBedrock &&
BEDROCK_PROFILE_ONLY_IDS.includes(model.modelID ?? model.id)
)
continue
catalog.model.update(provider.info.id, model.id, (draft) => Object.assign(draft, copy(model)))
}
}
@@ -18,32 +18,6 @@ const isBedrock = (item: { readonly package: string }) => {
return name.startsWith("@ai-sdk/amazon-bedrock") || name.startsWith("@opencode/ai/providers/amazon-bedrock")
}
// Bare Bedrock model IDs that AWS rejects unless sent as an inference-profile
// ID (`us.`/`eu.`/`global.`/...). Verified via on-demand foundation-model
// listings across six regions plus live Converse probes, all returning "with
// on-demand throughput isn't supported. Retry ... with an inference profile".
// V1 rewrites these to profiles at request time so they must stay in
// models.dev; V2 sends IDs verbatim, so listing them only produces errors.
// Interim until per-entry source-region metadata lands; region-aware
// filtering will subsume this list then.
export const PROFILE_ONLY_BARE_IDS = [
"amazon.nova-2-lite-v1:0",
"anthropic.claude-fable-5",
"anthropic.claude-fable-5-1",
"anthropic.claude-haiku-4-5-20251001-v1:0",
"anthropic.claude-opus-4-1-20250805-v1:0",
"anthropic.claude-opus-4-5-20251101-v1:0",
"anthropic.claude-opus-4-6-v1",
"anthropic.claude-opus-4-7",
"anthropic.claude-opus-4-8",
"anthropic.claude-opus-5",
"anthropic.claude-sonnet-4-5-20250929-v1:0",
"anthropic.claude-sonnet-4-6",
"anthropic.claude-sonnet-5",
"deepseek.r1-v1:0",
"mistral.pixtral-large-2502-v1:0",
]
export const AmazonBedrockPlugin = define({
id: "opencode.provider.amazon.bedrock",
effect: Effect.fn(function* (ctx) {
@@ -79,12 +53,6 @@ export const AmazonBedrockPlugin = define({
}
delete provider.settings.endpoint
})
for (const modelID of PROFILE_ONLY_BARE_IDS) {
if (!evt.model.get(item.provider.id, modelID)) continue
evt.model.update(item.provider.id, modelID, (model) => {
model.enabled = false
})
}
}
})
}),
+4 -1
View File
@@ -162,7 +162,10 @@ export const AzurePlugin = define({
draft.settings.baseURL,
resolveResourceName(draft.settings, resourceName) ?? resourceName,
)
if (responsesWebSocketCapable(item.provider, draft)) draft.capabilities.responsesWebsockets = true
if (responsesWebSocketCapable(item.provider, draft)) {
draft.capabilities.responsesWebsockets = true
draft.websocket = true
}
})
}
}
+3 -1
View File
@@ -20,7 +20,8 @@ const pollingSafetyMargin = 3000
const codexBaseURL = "https://chatgpt.com/backend-api/codex"
const browserMethodID = Integration.MethodID.make("chatgpt-browser")
const headlessMethodID = Integration.MethodID.make("chatgpt-headless")
const codexAllowed = new Set(["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"])
// ChatGPT accounts lost gpt-5.4 and gpt-5.4-mini in Codex on 2026-08-31 (replacements: gpt-5.6-terra, gpt-5.6-luna).
const codexAllowed = new Set(["gpt-5.5", "gpt-5.3-codex-spark"])
const codexDisallowed = new Set(["gpt-5.5-pro", "gpt-5.6"])
type Pkce = {
@@ -256,6 +257,7 @@ export const OpenAIPlugin = define({
for (const model of item.models.values()) {
evt.model.update(item.provider.id, model.id, (draft) => {
draft.capabilities.responsesWebsockets = true
draft.websocket = true
})
}
if (!chatgpt) return
+14 -21
View File
@@ -11,7 +11,6 @@ import {
Message,
type ContentPart,
} from "@opencode/ai"
import { Agent } from "@opencode/schema/agent"
import { SessionError } from "@opencode/schema/session-error"
import { Context, Effect, Layer, Stream } from "effect"
import { Bus } from "../bus.js"
@@ -97,7 +96,7 @@ export type Editor = {
export type AutoInput = {
readonly context: SessionContext.Loaded
readonly prepare: SessionModelRequest.Interface["prepare"]
readonly prepare: SessionModelRequest.Interface["compaction"]
/** Known overflow must recover from durable history, not submit the overflowing native window again. */
readonly overflow?: boolean
}
@@ -120,7 +119,7 @@ export type ManualInput = {
SessionContext.Loaded & { readonly instructionUpdate: string },
SessionRunnerModel.Error | AgentNotFoundError | Instructions.InitializationBlocked
>
readonly prepare: SessionModelRequest.Interface["prepare"]
readonly prepare: SessionModelRequest.Interface["compaction"]
}
type ExecuteInput = AutoInput & {
@@ -428,22 +427,16 @@ export const layer = Layer.effect(
messages,
})
return input.prepare({
kind: "compaction",
scope: {
session: context.session,
agentID: Agent.ID.make("compaction"),
contextAgentID: context.agent.id,
model: context.model,
tools: context.tools,
},
transcript: {
system: transcript.system,
messages: [
...transcript.messages,
...(input.instructionUpdate ? [Message.system(input.instructionUpdate)] : []),
...prompt,
],
},
session: context.session,
agent: context.agent.id,
model: context.model,
tools: context.tools,
system: transcript.system,
messages: [
...transcript.messages,
...(input.instructionUpdate ? [Message.system(input.instructionUpdate)] : []),
...prompt,
],
webSocket,
})
}
@@ -478,7 +471,7 @@ export const layer = Layer.effect(
"Provider compaction requires the endpoint in provider/model settings, not a model.request rewrite",
)
const transient = SessionRunnerRetry.transient(yield* SessionRunnerRetry.policy(context.session.id), {
agent: Agent.ID.make("compaction"),
agent: context.agent.id,
model: context.model.ref,
hook: prepared.retry,
})
@@ -580,7 +573,7 @@ export const layer = Layer.effect(
])
// Both requests share the retry allowance; rejected output never enters the reminder request.
const transient = SessionRunnerRetry.transient(yield* SessionRunnerRetry.policy(context.session.id), {
agent: Agent.ID.make("compaction"),
agent: context.agent.id,
model: context.model.ref,
hook: prepared.retry,
})
+3 -3
View File
@@ -65,7 +65,7 @@ export interface Interface {
}
| undefined
>
readonly prepare: SessionModelRequest.Interface["prepare"]
readonly request: SessionModelRequest.Interface
}
/** Location-scoped model-context loader for durable Session Steps. */
@@ -84,7 +84,7 @@ const layer = Layer.effect(
const mcpInstructions = yield* McpInstructions.Service
const mcpTools = yield* McpTool.Service
const models = yield* SessionRunnerModel.Service
const modelRequests = yield* SessionModelRequest.Service
const request = yield* SessionModelRequest.Service
const referenceInstructions = yield* ReferenceInstructions.Service
const skillInstructions = yield* SkillInstructions.Service
const store = yield* SessionStore.Service
@@ -173,7 +173,7 @@ const layer = Layer.effect(
}
})
return Service.of({ select, load, resolveModel, selectTitle, prepare: modelRequests.prepare })
return Service.of({ select, load, resolveModel, selectTitle, request })
}),
)
+11 -11
View File
@@ -43,17 +43,17 @@ export const generate = Effect.fn("SessionGenerate.generate")(function* (input:
initial: history.initial,
messages: history.messages,
})
const prepared = yield* context.prepare({
kind: "generate",
scope: { session: selection.session, agentID: selection.agent.id, model, tools: selection.tools },
transcript: {
system: transcript.system,
messages: [
...transcript.messages,
...(history.instructionUpdate ? [Message.system(history.instructionUpdate)] : []),
Message.user(input.prompt),
],
},
const prepared = yield* context.request.generate({
session: selection.session,
agent: selection.agent.id,
model,
tools: selection.tools,
system: transcript.system,
messages: [
...transcript.messages,
...(history.instructionUpdate ? [Message.system(history.instructionUpdate)] : []),
Message.user(input.prompt),
],
})
yield* Effect.logInfo("sending session generation request", {
sessionID: selection.session.id,
+161 -209
View File
@@ -1,12 +1,21 @@
export * as SessionModelRequest from "./model-request.js"
import { HttpOptions, LanguageModel, LLM, LLMRequest, Message, SystemPart } from "@opencode/ai"
import {
GenerationOptions,
type GenerationOptionsFields,
HttpOptions,
LanguageModel,
LLM,
LLMRequest,
Message,
SystemPart,
} from "@opencode/ai"
import type { StreamOptions } from "@opencode/ai/route"
import type { SessionRequestKind } 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"
import { Cause, Config, Context, Effect, Layer, Result, Stream } from "effect"
import { Cause, Context, Effect, Layer, Result, Stream } from "effect"
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { makeLocationNode } from "@opencode/util/effect/app-node"
import { App } from "../app.js"
@@ -26,63 +35,31 @@ const IMAGE_BYTES_TRIGGER = 25 * 1024 * 1024 // 25 MiB
const IMAGE_BYTES_TARGET = 15 * 1024 * 1024 // 15 MiB
const IMAGE_REMOVED =
"[This image was removed to reduce the request size and is no longer visible. Do not make claims about its contents from memory. If needed, retrieve it again with an available tool or ask the user to attach it again.]"
const GENERATION_KEYS = new Set(Object.keys(GenerationOptions.fields))
const responsesWebSocketFlag = (providerID: string) =>
`OPENCODE_EXPERIMENTAL_${providerID.replace(/[^a-zA-Z0-9]+/g, "_").toUpperCase()}_RESPONSES_WEBSOCKET`
/** Failures a prepared execution can surface: infrastructure errors plus user declines resurfaced from the defect tunnel. */
/** Tool errors, plus the user declining a permission or dismissing a question. */
export type ExecuteError = Tool.Error | Permission.DeclinedError | QuestionTool.CancelledError
// User declines dive under the leaves' blanket `mapError` as defects (the deliberate
// tunnel entered in Permission.assert and the question tool), so a user's "no" can
// never become model-facing tool output. They resurface as typed failures exactly once,
// here at the seam the runner executes through.
const declineDefect = (cause: Cause.Cause<Tool.Error>) => {
const decline = cause.reasons.flatMap((reason) =>
Cause.isDieReason(reason) &&
(reason.defect instanceof Permission.DeclinedError || reason.defect instanceof QuestionTool.CancelledError)
? [reason.defect]
: [],
)[0]
return decline ? Result.succeed(decline) : Result.fail(cause)
}
export interface Prepared {
export interface Prepared<Event = SessionRequest> {
readonly event: Event
readonly request: LLMRequest
readonly options: StreamOptions
readonly retry: (event: PluginHooks.Domains["session"]["retry"]) => Effect.Effect<void>
/**
* One request-scoped execution operation. Unknown and hook-removed calls
* fail individually through the same seam.
*/
/** Runs a tool call against the tools this request advertised. */
readonly executeTool: (
input: Parameters<Tool.Snapshot["execute"]>[0],
) => Effect.Effect<Tool.NormalizedResult, ExecuteError>
}
interface PrepareInput {
/** Which Session flow issues this request; request hooks receive it alongside the Session identity. */
readonly kind: SessionRequestKind
readonly scope: {
readonly session: SessionSchema.Info
readonly agentID: Agent.ID
/** Agent whose context an auxiliary request reuses, without changing its request-hook identity. */
readonly contextAgentID?: Agent.ID
readonly model: SessionRunnerModel.Resolved
/** Omitted for requests that carry no tool definitions, such as titles. */
readonly tools?: Tool.Snapshot
}
readonly transcript: {
readonly system: Array<SystemPart>
readonly messages: Array<Message>
}
export interface Input {
readonly session: SessionSchema.Info
readonly agent: Agent.ID
readonly model: SessionRunnerModel.Resolved
readonly tools?: Tool.Snapshot
readonly system: Array<SystemPart>
readonly messages: Array<Message>
readonly toolChoice?: LLM.RequestInput["toolChoice"]
/**
* Session context hooks shape the agent conversation. Standalone requests
* such as titles opt out; compaction uses the selected Session context.
*/
readonly contextHooks?: false
/** Stateful Session WebSocket channels require an explicit durable-runner opt-in. */
/** Only the durable runner may use a stateful WebSocket. */
readonly webSocket?: "session"
}
@@ -196,90 +173,16 @@ export const boundImages = (messages: LLMRequest["messages"]) => {
)
}
/** The identity a plugin hook sees for one outbound request. */
interface HookScope {
readonly sessionID: SessionSchema.ID
readonly agent: Agent.ID
readonly model: Model.Ref
readonly kind: SessionRequestKind
}
type Definitions = PluginHooks.Domains["session"]["context"]["tools"]
const sessionHeaders = (session: Pick<SessionSchema.Info, "id" | "parentID" | "projectID">, app: App.Info) => ({
"x-session-affinity": session.id,
"X-Session-Id": session.id,
...(session.parentID ? { "x-parent-session-id": session.parentID } : {}),
"User-Agent": App.useragent(app),
"x-opencode-project": session.projectID,
"x-opencode-session": session.id,
"x-opencode-client": app.name,
})
const promptCacheKey = (sessionID: SessionSchema.ID) =>
/^ses_[0-9a-f]{64}$/.test(sessionID) ? sessionID.slice(4) : sessionID
// Lets session.model.request hooks rewrite the base URL and headers before dispatch.
const applyModelHooks = (hooks: PluginHooks.Interface, scope: HookScope, request: LLMRequest) =>
Effect.gen(function* () {
const currentBaseURL = request.model.route.endpoint.baseURL
const event = yield* hooks.trigger("session", "model.request", {
...scope,
baseURL: typeof currentBaseURL === "string" ? currentBaseURL : undefined,
headers: { ...request.http?.headers },
})
const route =
event.baseURL !== undefined && event.baseURL !== currentBaseURL
? request.model.route.with({ endpoint: { baseURL: event.baseURL } })
: request.model.route
return LLMRequest.update(request, {
model: route === request.model.route ? request.model : LanguageModel.update(request.model, { route }),
http: new HttpOptions({
body: request.http?.body,
headers: Object.keys(event.headers).length === 0 ? undefined : event.headers,
query: request.http?.query,
}),
})
})
// Exposes each outbound HTTP exchange to session.http.request/response hooks
// through web-standard Request/Response values.
const httpMiddleware =
(hooks: PluginHooks.Interface, scope: HookScope): NonNullable<StreamOptions["http"]> =>
(request, handler) =>
Effect.gen(function* () {
const before = yield* hooks.trigger("session", "http.request", {
...scope,
request: yield* HttpClientRequest.toWeb(request),
})
let sent = HttpClientRequest.fromWeb(before.request)
if (before.request.body)
sent = HttpClientRequest.bodyUint8Array(
sent,
new Uint8Array(yield* Effect.promise(() => before.request.clone().arrayBuffer())),
before.request.headers.get("content-type") ?? undefined,
)
const response = yield* handler(sent)
const after = yield* hooks.trigger("session", "http.response", {
...scope,
request: before.request,
response: new Response(
[204, 205, 304].includes(response.status) ? null : yield* Stream.toReadableStreamEffect(response.stream),
{ status: response.status, headers: response.headers },
),
})
return HttpClientResponse.fromWeb(sent, after.response)
}).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause)))))
/**
* Builds an outbound model request and captures the tool-call capability that
* must remain paired with it. It does not execute the request or mutate
* Session state.
*/
/** Builds the model request for each session flow. Each entry runs its own plugin hook. */
export interface Interface {
/** Builds one outbound model request and its matching tool-call capability. */
readonly prepare: (input: PrepareInput) => Effect.Effect<Prepared>
readonly primary: (input: Input) => Effect.Effect<Prepared<SessionContext>>
readonly compaction: (input: Input) => Effect.Effect<Prepared<SessionContext>>
readonly generate: (input: Input) => Effect.Effect<Prepared<SessionContext>>
readonly title: (input: Input) => Effect.Effect<Prepared<SessionTitle>>
}
/** Location-scoped outbound model-request preparation. */
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionModelRequest") {}
export const layer = Layer.effect(
@@ -288,117 +191,166 @@ export const layer = Layer.effect(
const hooks = yield* PluginHooks.Service
const transport = yield* SessionModelTransport.Service
const app = yield* App.Metadata
const prepare = Effect.fn("SessionModelRequest.prepare")(function* (input: PrepareInput) {
const session = input.scope.session
const resolved = input.scope.model
const model = resolved.model
const tools = input.scope.tools ?? {
const prepare = Effect.fn("SessionModelRequest.prepare")(function* <
S extends SessionRequest & { tools?: Definitions },
>(kind: SessionRequestKind, input: Input, shape: (draft: SessionRequest, tools: Definitions) => Effect.Effect<S>) {
const session = input.session
const model = input.model
const scope = { sessionID: session.id, agent: input.agent, model: model.ref, kind }
const tools = input.tools ?? {
definitions: [],
execute: () => new Tool.Error({ message: "Tools are not available for this request" }),
}
const registry = new Map(tools.definitions.map((tool) => [tool.name, tool]))
// The definition objects we hand to hooks, mapped back to their tools. Hooks rename a
// tool by moving its definition to a new key; recognizing the object recovers the tool.
// Remember which tool each definition object came from. Hooks rename a tool by moving
// its definition to a new key, so after the hook we find the tool by object identity.
const given = new Map(
tools.definitions.map(
(tool) => [{ description: tool.description, input: { ...tool.inputSchema } }, tool] as const,
),
tools.definitions.map((t) => [{ description: t.description, input: { ...t.inputSchema } }, t] as const),
)
// Hooks mutate this record in place: edit descriptions and schemas, rename, or remove.
const definitions = Object.fromEntries(Array.from(given, ([definition, tool]) => [tool.name, definition]))
const context: PluginHooks.Domains["session"]["context"] = {
sessionID: session.id,
agent: input.scope.contextAgentID ?? input.scope.agentID,
model: resolved.ref,
system: input.transcript.system,
messages: input.transcript.messages,
tools: definitions,
generation: {},
providerOptions: {},
}
if (input.contextHooks !== false) yield* hooks.trigger("session", "context", context)
// Match each surviving entry back to its tool, by recognizing a moved definition or
// by key. Identity wins so a definition moved onto another tool's name still executes
// the tool it describes. Entries matching neither were invented by a hook and dropped.
// `tool.name` stays canonical so execution can translate renamed calls back.
const shaped = yield* shape(
{ sessionID: session.id, model: model.ref, system: input.system, messages: input.messages, options: {} },
Object.fromEntries(Array.from(given, ([d, t]) => [t.name, d])),
)
// Match by identity first, then by key. Entries matching neither were invented by a
// hook and are dropped. `t.name` stays the real name so execution can map renames back.
const byName = new Map(tools.definitions.map((t) => [t.name, t]))
const hooked = new Map(
Object.entries(context.tools).flatMap(([name, definition]) => {
const tool = given.get(definition) ?? registry.get(name)
if (!tool) return []
return [[name, { ...tool, description: definition.description, inputSchema: definition.input }] as const]
Object.entries(shaped.tools ?? {}).flatMap(([name, d]) => {
const t = given.get(d) ?? byName.get(name)
return t ? [[name, { ...t, description: d.description, inputSchema: d.input }] as const] : []
}),
)
const request = yield* applyModelHooks(
hooks,
{ sessionID: session.id, agent: input.scope.agentID, model: resolved.ref, kind: input.kind },
LLM.request({
model,
http: {
headers: sessionHeaders(session, app),
const entries = Object.entries(shaped.options)
const generation = Object.fromEntries(entries.filter(([k]) => GENERATION_KEYS.has(k))) as GenerationOptionsFields
const providerOptions = Object.fromEntries(entries.filter(([k]) => !GENERATION_KEYS.has(k)))
const root = session.fork?.sessionID ?? session.id
const base = LLM.request({
model: model.model,
http: {
headers: {
"x-session-affinity": session.id,
"X-Session-Id": session.id,
...(session.parentID ? { "x-parent-session-id": session.parentID } : {}),
"User-Agent": App.useragent(app),
"x-opencode-project": session.projectID,
"x-opencode-session": session.id,
"x-opencode-client": app.name,
},
// TODO: Persist cache lineage so nested forks reuse the root session's cache key.
promptCacheKey: promptCacheKey(session.fork?.sessionID ?? session.id),
system: context.system,
messages: boundImages(unsupportedParts(context.messages, resolved.capabilities)),
tools: Array.from(hooked, ([name, tool]) => ({ ...tool, name })),
toolChoice: input.toolChoice,
generation: Object.keys(context.generation).length === 0 ? undefined : context.generation,
providerOptions: Object.keys(context.providerOptions).length === 0 ? undefined : context.providerOptions,
},
// TODO: Persist cache lineage so nested forks reuse the root session's cache key.
promptCacheKey: /^ses_[0-9a-f]{64}$/.test(root) ? root.slice(4) : root,
system: shaped.system,
messages: boundImages(unsupportedParts(shaped.messages, model.capabilities)),
tools: Array.from(hooked, ([name, t]) => ({ ...t, name })),
toolChoice: input.toolChoice,
generation: Object.keys(generation).length === 0 ? undefined : generation,
providerOptions: Object.keys(providerOptions).length === 0 ? undefined : providerOptions,
})
const baseURL = base.model.route.endpoint.baseURL
const modelHook = yield* hooks.trigger("session", "model.request", {
...scope,
baseURL: typeof baseURL === "string" ? baseURL : undefined,
headers: { ...base.http?.headers },
})
const route =
modelHook.baseURL !== undefined && modelHook.baseURL !== baseURL
? base.model.route.with({ endpoint: { baseURL: modelHook.baseURL } })
: base.model.route
const request = LLMRequest.update(base, {
model: route === base.model.route ? base.model : LanguageModel.update(base.model, { route }),
http: new HttpOptions({
body: base.http?.body,
headers: Object.keys(modelHook.headers).length === 0 ? undefined : modelHook.headers,
query: base.http?.query,
}),
)
})
// History selects native windows against the catalog route before hooks run. A newly installed
// routing hook must not send an existing opaque window to another deployment; `prepare` has no
// error channel, so like hook failures this surfaces as a defect.
const selected = SessionProviderContext.provenance(resolved)
const selected = SessionProviderContext.provenance(model)
if (
selected &&
!SessionProviderContext.compatible(
selected,
SessionProviderContext.provenance({ model: request.model, ref: resolved.ref }),
SessionProviderContext.provenance({ model: request.model, ref: model.ref }),
) &&
request.messages.some((message) => message.content.some((part) => part.type === "compaction"))
)
return yield* Effect.die(
new Error("Provider context is incompatible with the route selected by model request hooks"),
)
const hasHttpHooks =
(yield* hooks.has("session", "http.request", resolved.ref.providerID)) ||
(yield* hooks.has("session", "http.response", resolved.ref.providerID))
const webSocket =
resolved.capabilities.responsesWebsockets === true
? yield* Config.boolean(responsesWebSocketFlag(resolved.ref.providerID)).pipe(
Config.withDefault(false),
Effect.orDie,
)
: false
const http = hasHttpHooks
? httpMiddleware(hooks, {
sessionID: session.id,
agent: input.scope.agentID,
model: resolved.ref,
kind: input.kind,
})
(yield* hooks.has("session", "http.request", model.ref.providerID)) ||
(yield* hooks.has("session", "http.response", model.ref.providerID))
const http: StreamOptions["http"] = hasHttpHooks
? (req, handler) =>
Effect.gen(function* () {
const before = yield* hooks.trigger("session", "http.request", {
...scope,
request: yield* HttpClientRequest.toWeb(req),
})
let sent = HttpClientRequest.fromWeb(before.request)
if (before.request.body)
sent = HttpClientRequest.bodyUint8Array(
sent,
new Uint8Array(yield* Effect.promise(() => before.request.clone().arrayBuffer())),
before.request.headers.get("content-type") ?? undefined,
)
const res = yield* handler(sent)
const after = yield* hooks.trigger("session", "http.response", {
...scope,
request: before.request,
response: new Response(
[204, 205, 304].includes(res.status) ? null : yield* Stream.toReadableStreamEffect(res.stream),
{ status: res.status, headers: res.headers },
),
})
return HttpClientResponse.fromWeb(sent, after.response)
}).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause)))))
: undefined
const options: StreamOptions = {
...(http ? { http } : {}),
...(input.webSocket === "session" && webSocket && !hasHttpHooks
? { webSocket: transport.bind(session.id) }
: {}),
}
const executeTool: Prepared["executeTool"] = (input) =>
tools
.execute({ ...input, definitions: hooked })
.pipe(Effect.catchCauseFilter(declineDefect, (decline) => Effect.fail(decline)))
const retry: Prepared["retry"] = (event) => hooks.trigger("session", "retry", event).pipe(Effect.asVoid)
// HTTP hooks must observe every request, so they keep the provider on HTTP.
const webSocket =
input.webSocket === "session" &&
!hasHttpHooks &&
model.capabilities.responsesWebsockets === true &&
model.websocket
return {
event: shaped,
request,
options,
retry,
executeTool,
options: { ...(http ? { http } : {}), ...(webSocket ? { webSocket: transport.bind(session.id) } : {}) },
retry: (event: Parameters<Prepared["retry"]>[0]) =>
hooks.trigger("session", "retry", event).pipe(Effect.asVoid),
// Permission.assert and the question tool throw declines as defects so tools cannot
// catch them and turn a "no" into model-visible output. Recover them here as failures.
executeTool: (call: Parameters<Prepared["executeTool"]>[0]) =>
tools.execute({ ...call, definitions: hooked }).pipe(
Effect.catchCauseFilter(
(cause) => {
const decline = cause.reasons.flatMap((r) =>
Cause.isDieReason(r) &&
(r.defect instanceof Permission.DeclinedError || r.defect instanceof QuestionTool.CancelledError)
? [r.defect]
: [],
)[0]
return decline ? Result.succeed(decline) : Result.fail(cause)
},
(decline) => Effect.fail(decline),
),
),
}
})
return Service.of({ prepare })
const context = (agent: Agent.ID) => (draft: SessionRequest, tools: Definitions) =>
hooks.trigger("session", "context", { ...draft, agent, tools })
return Service.of({
primary: (input) => prepare("primary", input, context(input.agent)),
generate: (input) => prepare("generate", input, context(input.agent)),
compaction: (input) => prepare("compaction", input, context(input.agent)),
title: (input) => prepare("title", input, (draft) => hooks.trigger("session", "title", draft)),
})
}),
)
+31 -15
View File
@@ -20,6 +20,7 @@ import { webSocketConstructor } from "../effect/app-node-platform.js"
const ROTATE_AFTER_MS = 55 * 60 * 1000
const INBOUND_CAPACITY = 128
const CONNECT_TIMEOUT = "10 seconds"
const IDLE_TIMEOUT = "5 minutes"
const events = Metric.counter("opencode_session_websocket_events_total", {
description: "Session WebSocket lifecycle events",
@@ -167,7 +168,20 @@ export const makeLayer = (connector: WebSocketConnector) =>
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const connection = yield* restore(
connector.open(exchange.connect).pipe(Effect.withSpan("SessionModelTransport.connect")),
connector.open(exchange.connect).pipe(
Effect.timeoutOrElse({
duration: CONNECT_TIMEOUT,
orElse: () =>
transportError("Timed out opening the Session WebSocket", {
url: exchange.connect.url,
operation: "request",
code: "connect-timeout",
phase: "connect",
delivery: "not-sent",
}),
}),
Effect.withSpan("SessionModelTransport.connect"),
),
)
if (owner.closed) {
yield* connection.close
@@ -294,20 +308,22 @@ export const makeLayer = (connector: WebSocketConnector) =>
const channel = owner.channel
? owner.channel
: yield* open(owner, exchange, key).pipe(
Effect.catch((error) =>
error.reason._tag === "Transport" && error.reason.code === "owner-closed"
? Effect.fail(error)
: Effect.logWarning("session websocket connect failed; using http", {
sessionTransport: "websocket",
phase: "connect",
delivery: "not-sent",
code: error.reason._tag === "Transport" ? error.reason.code : error.reason._tag,
}).pipe(
Effect.andThen(metric("connect_failure")),
Effect.andThen(metric("fallback")),
Effect.as(undefined),
),
),
Effect.catch((error) => {
if (error.reason._tag === "Transport" && error.reason.code === "owner-closed") return Effect.fail(error)
// Any connect failure, transient or not, pins the Session to HTTP until restart or move:
// a network that refuses the upgrade would otherwise charge every step for a failed connect.
owner.httpFallback = true
return Effect.logWarning("session websocket connect failed; using http", {
sessionTransport: "websocket",
phase: "connect",
delivery: "not-sent",
code: error.reason._tag === "Transport" ? error.reason.code : error.reason._tag,
}).pipe(
Effect.andThen(metric("connect_failure")),
Effect.andThen(metric("fallback")),
Effect.as(undefined),
)
}),
)
if (!channel) return fallback(exchange)
+11 -11
View File
@@ -130,7 +130,7 @@ const layer = Layer.effect(
instructionUpdate: history.instructionUpdate,
}
}),
prepare: context.prepare,
prepare: context.request.compaction,
messages: yield* store.context(sessionID),
inputID: pending.id,
started: true,
@@ -209,7 +209,7 @@ const layer = Layer.effect(
initial = undefined
const compactionInput = {
context: loaded,
prepare: context.prepare,
prepare: context.request.compaction,
}
if (compaction.required({ messages: loaded.messages, resolved: loaded.model, context: loaded })) {
const result = yield* compaction.compact(compactionInput)
@@ -226,15 +226,15 @@ const layer = Layer.effect(
initial: loaded.initial,
messages: loaded.messages,
})
const prepared = yield* context.prepare({
kind: "primary",
scope: { session: loaded.session, agentID: loaded.agent.id, model: loaded.model, tools: loaded.tools },
transcript: {
system: transcript.system,
messages: stepLimitReached
? [...transcript.messages, Message.assistant(MAX_STEPS_PROMPT)]
: transcript.messages,
},
const prepared = yield* context.request.primary({
session: loaded.session,
agent: loaded.agent.id,
model: loaded.model,
tools: loaded.tools,
system: transcript.system,
messages: stepLimitReached
? [...transcript.messages, Message.assistant(MAX_STEPS_PROMPT)]
: transcript.messages,
// Keep tool definitions on the final Step to preserve the provider's cached prefix.
toolChoice: stepLimitReached ? "none" : undefined,
webSocket: "session",
@@ -60,6 +60,7 @@ export const resolved = (
readonly cost: Model.Info["cost"]
readonly limit: Model.Info["limit"]
readonly compaction?: Provider.Compaction
readonly websocket?: boolean
},
): Resolved => ({
model,
@@ -72,6 +73,7 @@ export const resolved = (
cost: options.cost,
limit: options.limit,
compaction: options.compaction,
websocket: options.websocket ?? false,
})
const layer = Layer.effect(
+3 -1
View File
@@ -35,8 +35,10 @@ export function isRetryable(error: AIError) {
case "RateLimit":
case "ProviderInternal":
return true
// HTTP transport errors carry no delivery and always retry. WebSocket marks accepted and rejected
// requests as final; not-sent and ambiguous (no frame observed) are still pre-output.
case "Transport":
return error.reason.delivery === undefined || error.reason.delivery === "not-sent"
return error.reason.delivery !== "accepted" && error.reason.delivery !== "rejected"
case "InvalidProviderOutput":
return error.reason.classification === "incomplete-stream"
// Unrecognized failures retry: classification records affirmative
+1 -1
View File
@@ -46,7 +46,7 @@ interface Input {
readonly assistantMessageID: SessionMessage.ID
readonly agent: Agent.ID
readonly model: SessionRunnerModel.Resolved
readonly prepared: SessionModelRequest.Prepared
readonly prepared: Omit<SessionModelRequest.Prepared, "event">
readonly retry: (
cause: AIError,
error: SessionError.Error,
+7 -8
View File
@@ -63,15 +63,14 @@ export const layer = Layer.effect(
})
: Effect.void,
)
const prepared = yield* context.prepare({
kind: "title",
scope: { session: input.session, agentID: input.agent.id, model: input.model },
transcript: {
system: input.agent.system ? [SystemPart.make(input.agent.system)] : [],
messages: [Message.user(input.text)],
},
contextHooks: false,
const prepared = yield* context.request.title({
session: input.session,
agent: input.agent.id,
model: input.model,
system: input.agent.system ? [SystemPart.make(input.agent.system)] : [],
messages: [Message.user(input.text)],
})
if (prepared.event.result !== undefined) return prepared.event.result
yield* llm.stream(prepared.request, prepared.options).pipe(
Stream.runForEach((event) => {
if (LLMEvent.is.providerError(event)) failed = true
+20 -3
View File
@@ -251,11 +251,28 @@ describe("AISDKNative", () => {
},
})
for (const modelID of ["openai.gpt-oss-120b-1:0", "global.openai.gpt-5.6-sol", "us.openai.gpt-5.6-sol"]) {
// gpt-oss (Harmony) keeps the flat chat-completions field.
expect(
map("@ai-sdk/amazon-bedrock", { reasoningConfig: { maxReasoningEffort: "high" } }, "openai.gpt-oss-120b-1:0")
?.body,
).toEqual({ additionalModelRequestFields: { reasoning_effort: "high" } })
// GPT-5.6+ reject `reasoning_effort` and take the Responses-style nested field.
for (const modelID of ["global.openai.gpt-5.6-sol", "us.openai.gpt-5.6-sol", "us.openai.gpt-6-astra"]) {
expect(
map("@ai-sdk/amazon-bedrock", { reasoningConfig: { maxReasoningEffort: "high" } }, modelID)?.body,
).toEqual({ additionalModelRequestFields: { reasoning_effort: "high" } })
map("@ai-sdk/amazon-bedrock", { reasoningConfig: { maxReasoningEffort: "none" } }, modelID)?.body,
).toEqual({ additionalModelRequestFields: { reasoning: { effort: "none" } } })
}
expect(
map(
"@ai-sdk/amazon-bedrock",
{
reasoningConfig: { maxReasoningEffort: "high" },
additionalModelRequestFields: { reasoning: { summary: "auto" } },
},
"us.openai.gpt-5.6-sol",
)?.body,
).toEqual({ additionalModelRequestFields: { reasoning: { summary: "auto", effort: "high" } } })
})
test("maps Bedrock Mantle models to their supported native APIs", () => {
+92 -1
View File
@@ -6,6 +6,7 @@ import { Agent } from "@opencode/core/agent"
import { Bus } from "@opencode/core/bus"
import { Config } from "@opencode/core/config"
import { Directory, Document, Event, Info } from "@opencode/schema/config"
import { Model } from "@opencode/schema/model"
import { ConfigAgentPlugin } from "@opencode/core/config/plugin/agent"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { LayerNode } from "@opencode/util/effect/layer-node"
@@ -17,7 +18,7 @@ import { AbsolutePath } from "@opencode/core/schema"
import { ConfigMigrateV1 } from "@opencode/core/v1/config/migrate"
import { ConfigAgentV1 } from "@opencode/core/v1/config/agent"
import { advance, drain } from "../lib/clock"
import { tmpdir } from "../fixture/tmpdir"
import { tmpdir, tmpdirScoped } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
import { agentHost, host } from "../plugin/host"
@@ -60,6 +61,76 @@ test("keeps schema fields and name out of legacy agent options", () => {
})
describe("ConfigAgentPlugin.Plugin", () => {
for (const item of [
{ name: "separate legacy variant", frontmatter: "model: example/chat\nvariant: high", model: "example/chat#high" },
{ name: "unqualified model", frontmatter: "model: example/chat", model: "example/chat" },
{ name: "embedded native variant", frontmatter: "model: example/chat#high", model: "example/chat#high" },
{
name: "structured native variant",
frontmatter: "model:\n providerID: example\n model: chat\n variant: high",
model: "example/chat#high",
},
{
name: "structured unqualified model",
frontmatter: "model:\n providerID: example\n model: chat",
model: "example/chat",
},
{ name: "standalone variant", frontmatter: "variant: high", model: undefined },
{
name: "embedded native variant with an ignored separate variant",
frontmatter: "model: example/chat#high\nvariant: low",
model: "example/chat#high",
},
{
name: "structured native variant with an ignored separate variant",
frontmatter: "model:\n providerID: example\n model: chat\n variant: high\nvariant: low",
model: "example/chat#high",
},
]) {
for (const native of [false, true]) {
it.live(`loads Markdown ${item.name}${native ? " with native request and permissions" : ""}`, () =>
Effect.gen(function* () {
const agent = yield* loadMarkdownAgent(
native
? `${item.frontmatter}
request:
headers:
x-agent: native
body:
effort: high
permissions:
- action: edit
resource: "*"
effect: deny`
: item.frontmatter,
)
expect(agent.model).toEqual(item.model === undefined ? undefined : Model.Ref.parse(item.model))
expect(agent.request).toEqual({
settings: {},
headers: native ? { "x-agent": "native" } : {},
body: native ? { effort: "high" } : {},
})
if (native) {
expect(agent.permissions).toContainEqual({ action: "edit", resource: "*", effect: "deny" })
expect(Permission.evaluate("edit", "example.txt", agent.permissions).effect).toBe("deny")
}
}),
)
}
}
for (const variant of [undefined, "high"]) {
it.live(`loads Markdown legacy temperature ${variant ? "with" : "without"} a separate variant`, () =>
Effect.gen(function* () {
const agent = yield* loadMarkdownAgent(
`model: example/chat\ntemperature: 0.5${variant ? `\nvariant: ${variant}` : ""}`,
)
expect(agent.model).toEqual(Model.Ref.parse(variant ? "example/chat#high" : "example/chat"))
expect(agent.request).toEqual({ settings: {}, headers: {}, body: { temperature: 0.5 } })
}),
)
}
it.effect("matches POSIX paths against home-relative permissions", () =>
Effect.gen(function* () {
const permissions = yield* loadHomePermissions("/home/test")
@@ -560,6 +631,26 @@ Use native v2 fields.`,
)
})
function loadMarkdownAgent(frontmatter: string) {
return Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
const fs = yield* FSUtil.Service
yield* fs.makeDirectory(path.join(tmp.path, "agents"))
yield* fs.writeFileString(
path.join(tmp.path, "agents", "reviewer.md"),
`---\n${frontmatter}\n---\nReview carefully.`,
)
const agents = yield* Agent.Service
yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
Effect.provide(Config.testLayer([directoryEntry(tmp.path)])),
)
const agent = yield* agents.get(Agent.ID.make("reviewer"))
if (!agent) throw new Error("expected configured Markdown agent")
expect(agent.system).toBe("Review carefully.")
return agent
})
}
function directoryEntry(directory: string) {
return new Directory({ type: "directory", path: AbsolutePath.make(directory) })
}
+1 -1
View File
@@ -95,7 +95,7 @@ describe("ConfigCompactionPlugin.Plugin", () => {
yield* compaction.compactManual({
session,
resolveContext: () => Effect.succeed({ ...nearInput.context, messages, instructionUpdate: "" }),
prepare: modelRequests.prepare,
prepare: modelRequests.compaction,
messages,
inputID: SessionMessage.ID.make("msg_compaction_manual"),
}),

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