mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-10 10:56:22 +00:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2af928895 | ||
|
|
0f67a15f3b | ||
|
|
08ac1e168c | ||
|
|
eb37a7ebc7 | ||
|
|
bf4522ed46 | ||
|
|
571c3c4f00 | ||
|
|
50ed7c41ef | ||
|
|
0bbf29fea6 | ||
|
|
a0a0e3271c | ||
|
|
7ed5223d5e | ||
|
|
1bd85d926b | ||
|
|
c45e425e12 | ||
|
|
7fb79388a4 | ||
|
|
7f2510c5ca | ||
|
|
9ae6b21f6a | ||
|
|
c7dd0c8278 | ||
|
|
85dff53a1f | ||
|
|
297019e321 | ||
|
|
95503c1773 | ||
|
|
ba1448325a | ||
|
|
be2582f316 | ||
|
|
c0cb1c7a91 | ||
|
|
e10408b219 | ||
|
|
bdc143c1d5 | ||
|
|
d52380024d | ||
|
|
f1eed8bf11 |
@@ -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.
|
||||
|
||||
@@ -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 }),
|
||||
})),
|
||||
|
||||
@@ -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()
|
||||
|
||||
+6
-6
File diff suppressed because one or more lines are too long
+4
-4
File diff suppressed because one or more lines are too long
Vendored
+4
-4
File diff suppressed because one or more lines are too long
+6
-6
File diff suppressed because one or more lines are too long
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+5
-5
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 })
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(""),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) }),
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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)),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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) })
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user