Compare commits

...
Author SHA1 Message Date
Kit Langton d6ce094a67 feat(core): close live capabilities and cache ownership 2026-08-27 00:54:41 -04:00
Kit Langton 8e9c33a0f2 refactor(core): reuse stable capability tool snapshots 2026-08-27 00:54:31 -04:00
Kit Langton 2fce8f8473 refactor(core): share capability adapters and fixtures 2026-08-27 00:54:24 -04:00
Kit Langton 4623f0a729 fix(core): isolate capability defaults from host layers 2026-08-27 00:20:31 -04:00
Kit Langton 6292a293f2 test(core): prove durable capability lifecycles 2026-08-27 00:17:06 -04:00
Kit Langton d223f06dee fix(core): preserve typed host permission declines 2026-08-27 00:16:59 -04:00
Kit Langton 1bfe98de78 feat(core): open durable sessions with live capabilities 2026-08-27 00:16:49 -04:00
Kit Langton 0145d90378 fix(core): keep capability-owned recovery pending 2026-08-27 00:05:08 -04:00
Kit Langton 32f3afbe74 feat(core): add live capability sources and value tool snapshots 2026-08-27 00:01:28 -04:00
Kit Langton 10786cb60c refactor(core): consolidate runner capability reads (#45448) 2026-08-26 23:45:01 -04:00
Kit Langton 1e7c60adce fix(server): wait for plugins before text generation (#45447)
Wait for bounded plugin readiness in the generation location before resolving explicit or default models. Add deterministic cold-first-request regressions through the embedded SDK.
2026-08-26 23:30:52 -04:00
Aiden Cline 1c4f8c40a8 feat(plugin): add tool draft reads (#45443) 2026-08-26 22:16:27 -05:00
Luke Parker 2ca55b479d fix(app): reduce tab switch rendering work (#45428) 2026-08-27 13:10:27 +10:00
opencode-agent[bot]andthdxr 8d7caa178b fix(core): route session events to location subscribers (#45411)
Co-authored-by: thdxr <826656+thdxr@users.noreply.github.com>
2026-08-26 23:04:27 -04:00
Aiden Cline 2bcb67a71e feat(plugin): add tool updates and removal (#45436) 2026-08-26 22:00:01 -05:00
Luke Parker 48d4e52143 fix(app): keep pending steers after assistant work (#45435) 2026-08-27 02:44:18 +00:00
Aiden Cline 40cbea3c19 refactor(core): use shared state for tool registry (#45414) 2026-08-26 21:33:00 -05:00
Luke Parker 51065122d8 fix(app): keep project extensions inside settings (#45432) 2026-08-27 02:30:36 +00:00
Luke Parker 7507f19a00 fix(app): prevent settings loading flicker (#45427) 2026-08-27 02:01:36 +00:00
opencode-agent[bot] 71706577c4 chore: update nix node_modules hashes 2026-08-27 01:53:01 +00:00
Kit Langton 66f326ddec fix(tui): restore default Kitty keyboard reporting (#45423) 2026-08-26 21:41:19 -04:00
opencode-agent[bot]andBrendonovich 83b81b39f1 fix(app): remove worktree user message accent (#45250)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-27 09:36:35 +08:00
Brendan Allan b20e4971db test(app): run CI e2e against production builds (#45422) 2026-08-27 09:28:23 +08:00
James Long c3378f2d34 fix(pty): upgrade opencode-pty to 0.1.12 (#45420) 2026-08-26 21:15:09 -04:00
Kit Langton d68031bc0d fix(core): preserve renamed files when undoing changes (#45383) 2026-08-26 20:42:40 -04:00
Kit Langton 4ac19d744a feat(tui): make session tab status explicit (#45347) 2026-08-26 20:12:15 -04:00
Aiden Cline b5cb5e2a55 refactor(plugin): manage MCP servers through transforms (#45408) 2026-08-26 18:42:28 -05:00
Luke Parker 53d171781a fix(app): reduce catalog retention and status animation work (#45406) 2026-08-26 23:30:49 +00:00
opencode-agent[bot]andthdxr 3184427c03 feat(plugin): expose the current location in plugin context (#45403)
Co-authored-by: thdxr <826656+thdxr@users.noreply.github.com>
2026-08-26 19:09:16 -04:00
James Long 6b0613ac6f fix(tui): restore session focus during permission prompts (#45391) 2026-08-26 19:02:56 -04:00
Aiden Cline 5ac4156eb2 fix(core): preserve reasoning across model switches (#45401) 2026-08-26 17:52:41 -05:00
Luke Parker 954cdc7bc8 fix(desktop): restore compact Windows titlebar spacing (#45397) 2026-08-26 22:18:05 +00:00
Aiden Cline 767115cd69 fix(ai): default native Mantle to Responses (#45394) 2026-08-26 16:47:39 -05:00
James Long 5b25ee8430 style(tui): brighten embedded terminal palette (#45393) 2026-08-26 17:30:23 -04:00
James Long df6317d23c refactor(tui): simplify terminal selection state (#45387) 2026-08-26 17:18:44 -04:00
opencode-agent[bot] 524e39db02 chore: update nix node_modules hashes 2026-08-26 20:58:41 +00:00
James Long 6170221e21 fix(pty): upgrade opencode-pty to 0.1.11 (#45380) 2026-08-26 16:39:24 -04:00
Kit Langton 74a0b2d0ad feat(core): make the portable shell scanner authoritative 2026-08-26 16:35:16 -04:00
opencode-agent[bot] 9d46941e70 chore: update nix node_modules hashes 2026-08-26 20:31:57 +00:00
Aiden Cline d48a5d16ff fix(core): avoid nested MCP code mode (#45377) 2026-08-26 15:23:14 -05:00
Kit Langton 6c392355dd feat(tui): render LaTeX as Unicode math (#45339) 2026-08-26 16:14:37 -04:00
Aiden Cline dd4ee94682 refactor(ai): remove derived HTTP request ID (#45376) 2026-08-26 15:12:53 -05:00
Aiden Cline 009eca8b5b test(core): shrink webfetch stress fixtures (#45375) 2026-08-26 15:03:49 -05:00
Aiden Cline 94bd9f6c8b fix(ai): isolate native provider metadata ownership (#45155) 2026-08-26 14:54:12 -05:00
224 changed files with 14498 additions and 2456 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Include both paths of renamed files in new snapshot change lists so undo restores the original file instead of only deleting the renamed file.
+8
View File
@@ -0,0 +1,8 @@
---
"@opencode-ai/core": minor
"@opencode-ai/schema": patch
---
Open durable sessions with in-process model, tool, instruction, and permission capabilities. Live Sources update at safe boundaries through existing instruction epochs, while capability replacement waits for the next busy period. Capability-owned sessions remain pending after restart until their host reopens and drives them.
Close an open's in-process capabilities after settlement without deleting durable history. Tool executors may yield domain errors, which normalize to tool failures while canonical permission declines retain their interruption behavior.
+10
View File
@@ -0,0 +1,10 @@
---
"@opencode-ai/core": patch
---
Make the experimental portable shell scanner authoritative, with no Tree-sitter
fallback. Scan common Bash and PowerShell control flow, heredocs, functions,
expressions, quoting, and substitutions natively. Preserve existing redirect and
declaration permission matching, and make PowerShell saved approvals cover the
original command spelling. Parser failures remain visible without changing the
permission engine. The default Tree-sitter path is unchanged.
+2 -2
View File
@@ -237,9 +237,9 @@ jobs:
working-directory: packages/app
run: bunx playwright install chromium
- name: Run app e2e tests
- name: Run app e2e tests against production build
if: env.E2E_ENABLED == 'true'
run: bun --cwd packages/app test:e2e:local
run: bun --cwd packages/app test:e2e:built
env:
CI: true
timeout-minutes: 30
+26 -9
View File
@@ -125,7 +125,7 @@
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/pty": "0.1.10",
"@opencode-ai/pty": "0.1.12",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",
@@ -364,7 +364,7 @@
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/pty": "0.1.10",
"@opencode-ai/pty": "0.1.12",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@parcel/watcher": "2.5.1",
@@ -553,6 +553,20 @@
"@typescript/native-preview": "catalog:",
},
},
"packages/latex": {
"name": "@opencode-ai/latex",
"version": "0.0.0",
"dependencies": {
"@opencode-ai/plugin": "workspace:*",
"@opentui/core": "catalog:",
"string-width": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/merman": {
"name": "@opencode-ai/merman",
"version": "0.0.0",
@@ -879,6 +893,7 @@
"dependencies": {
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/latex": "workspace:*",
"@opencode-ai/merman": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
@@ -2150,6 +2165,8 @@
"@opencode-ai/httpapi-codegen": ["@opencode-ai/httpapi-codegen@workspace:packages/httpapi-codegen"],
"@opencode-ai/latex": ["@opencode-ai/latex@workspace:packages/latex"],
"@opencode-ai/merman": ["@opencode-ai/merman@workspace:packages/merman"],
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
@@ -2158,19 +2175,19 @@
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
"@opencode-ai/pty": ["@opencode-ai/pty@0.1.10", "", { "optionalDependencies": { "@opencode-ai/pty-darwin-arm64": "0.1.10", "@opencode-ai/pty-darwin-x64": "0.1.10", "@opencode-ai/pty-linux-arm64-gnu": "0.1.10", "@opencode-ai/pty-linux-arm64-musl": "0.1.10", "@opencode-ai/pty-linux-x64-gnu": "0.1.10", "@opencode-ai/pty-linux-x64-musl": "0.1.10" }, "bin": { "opencode-pty": "bin/opencode-pty.js" } }, "sha512-cEJT1ADtmnb+df2wrlUcsGny6Q7pTe9Sa7keISzCO0xN1FrL1aS6+eleBPpDimHjgM/sXqvLwJv0UiAeiAvgxQ=="],
"@opencode-ai/pty": ["@opencode-ai/pty@0.1.12", "", { "optionalDependencies": { "@opencode-ai/pty-darwin-arm64": "0.1.12", "@opencode-ai/pty-darwin-x64": "0.1.12", "@opencode-ai/pty-linux-arm64-gnu": "0.1.12", "@opencode-ai/pty-linux-arm64-musl": "0.1.12", "@opencode-ai/pty-linux-x64-gnu": "0.1.12", "@opencode-ai/pty-linux-x64-musl": "0.1.12" }, "bin": { "opencode-pty": "bin/opencode-pty.js" } }, "sha512-dl4FyJUhTXThsWYY8txG/8/nwN7dE0M5Sic9r4L9f2pvtJnbR5zrCrPoiPIBIxZle1wVks1dhz4z/CfqLf5sCg=="],
"@opencode-ai/pty-darwin-arm64": ["@opencode-ai/pty-darwin-arm64@0.1.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-j7aszDFRwCIazGUT9eIy4PZwh4rltjvRmoicPRTK3kONN3v0MMflstkmAFDYYpqDPTNh3qJ6xkQmB+DugEbhAg=="],
"@opencode-ai/pty-darwin-arm64": ["@opencode-ai/pty-darwin-arm64@0.1.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tMvoriq3VegVlj1uEglc6qE0M7VXy61nyf9Si7tTO7xa8JiyxuFJSXOZ1pGeErDu+pe24hvTyVOR+gkdew8w9g=="],
"@opencode-ai/pty-darwin-x64": ["@opencode-ai/pty-darwin-x64@0.1.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-UAMP/E4lo9RGQF7xrfIwpW2ZEemj308rCogJy14ruKYJt5MwHeGNTynGiHE/1JlDLRy+21wV50jpugADgT71ag=="],
"@opencode-ai/pty-darwin-x64": ["@opencode-ai/pty-darwin-x64@0.1.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-Sn5vMLL5giHOhx7J5H6zwDp4YjjXorY+QV0IEYY+SCT4wQfRBliokIyj23pRl6P2RK3u9bDLXJHDNMfDVZ2Rxg=="],
"@opencode-ai/pty-linux-arm64-gnu": ["@opencode-ai/pty-linux-arm64-gnu@0.1.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-lTPlZNQ66koFHZqoPmvvq0SetlepKVQYgnLryhlVfYtcryWDJM7gV4+P66V12RwqWQTjt2u8j12mtg3axSKg2w=="],
"@opencode-ai/pty-linux-arm64-gnu": ["@opencode-ai/pty-linux-arm64-gnu@0.1.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-HbnlKZy052l7G527wK0+05EXaUpZ4ykVAmNBEzqWCoi4TeQj2+Nr9kJ9trx9o1KrVcT4Ki58CCvN5QOls6Z0yQ=="],
"@opencode-ai/pty-linux-arm64-musl": ["@opencode-ai/pty-linux-arm64-musl@0.1.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-IDmWHRylMR/ZfMw9/AAktO/Edi4TITPC+Tq7Xx3JZHsDgSba3QdyE11uNL0zM1myTGdk6Yrt4rpdAzaItPnDjw=="],
"@opencode-ai/pty-linux-arm64-musl": ["@opencode-ai/pty-linux-arm64-musl@0.1.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-2nTN7ggu1h9XgjNcoQMYjP5sirfYnAskpdFCOqjokLqhytX/IMMmkRTQs+foaEaPz0dAIQD3DQplR2jZIgxp1w=="],
"@opencode-ai/pty-linux-x64-gnu": ["@opencode-ai/pty-linux-x64-gnu@0.1.10", "", { "os": "linux", "cpu": "x64" }, "sha512-Q1yob0/8X2JoJZzFmNKUc32XDRAe0avKQ8PLKkpJr30qWXSrGmhltgcDmn94Q70zW9Ght9on84T7cmge9brvdQ=="],
"@opencode-ai/pty-linux-x64-gnu": ["@opencode-ai/pty-linux-x64-gnu@0.1.12", "", { "os": "linux", "cpu": "x64" }, "sha512-FnD5ndnObTQKAoaVvxLKi5W+r3/+dsaMsobz6uK0B9hlmffXxY5CQ6HQyUU/h3aIKLWXxho5XYkA2b9yrp8/gA=="],
"@opencode-ai/pty-linux-x64-musl": ["@opencode-ai/pty-linux-x64-musl@0.1.10", "", { "os": "linux", "cpu": "x64" }, "sha512-7RLHWQxX/wfUKJJP2ZMMtkXaPsrgoMNKzE6PL/LbnYbMBtkqfld9EDcMv1RFZ0CqjNFgI0Hg4eRk6x+ZNc/wyQ=="],
"@opencode-ai/pty-linux-x64-musl": ["@opencode-ai/pty-linux-x64-musl@0.1.12", "", { "os": "linux", "cpu": "x64" }, "sha512-prkrNu6uvjqoffxdGiDHSU5C0Y+kCSfv+lslu7dfRPgPKenVELNpRTAbOduyrWPac2vGt8j5NM61icJyodbJmA=="],
"@opencode-ai/schema": ["@opencode-ai/schema@workspace:packages/schema"],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-XHUy+Hk+RHUHREt4x0HfSzr3RlEvgBd4H/fV0rlXw2M=",
"aarch64-linux": "sha256-/gIaM62uV2X6KCnkSi6QjyT7t7uJ2L7u7CxXxPQYK+w=",
"aarch64-darwin": "sha256-PWG6ALh6kG7mnC6AzEuIAU54BEQ4IB+SyyQFGb/s+Dc=",
"x86_64-darwin": "sha256-TcgRDHG4CAT+XoCi0JNansONjg06ZPRmeLRsqmdJ4B4="
"x86_64-linux": "sha256-iYdVrLtyKmjlyypisF9SqzgyriWT90kSCh3crxw9AKU=",
"aarch64-linux": "sha256-BV2t4w5ujArbtSC/Qfm3gLzevQW9A6hMgOyPVp94g/o=",
"aarch64-darwin": "sha256-EwMq7zaxzzcsmH0Pjqu4ftGdcM8Lna8mvHgKzRcVI8g=",
"x86_64-darwin": "sha256-PokzxlkQy6JvHADF2ZMIIDI1u9ZjSNNedpmR9gvHS5c="
}
}
+42 -32
View File
@@ -406,6 +406,7 @@ const AnthropicEvent = Schema.Struct({
type AnthropicEvent = Schema.Schema.Type<typeof AnthropicEvent>
interface ParserState {
readonly providerMetadataKey: string
readonly tools: ToolStream.State<number>
readonly reasoningSignatures: Readonly<Record<number, string>>
readonly usage?: Usage
@@ -440,18 +441,18 @@ const cacheControl = (breakpoints: Cache.Breakpoints, cache: CacheHint | undefin
return Cache.ttlBucket(cache.ttlSeconds) === "1h" ? EPHEMERAL_1H : EPHEMERAL_5M
}
const anthropicMetadata = (metadata: Record<string, unknown>): ProviderMetadata => ({ anthropic: metadata })
const providerMetadata = (key: string, metadata: Record<string, unknown>): ProviderMetadata => ({ [key]: metadata })
const signatureFromMetadata = (metadata: ProviderMetadata | undefined): string | undefined => {
const anthropic = metadata?.anthropic
if (!ProviderShared.isRecord(anthropic)) return undefined
return typeof anthropic.signature === "string" ? anthropic.signature : undefined
const signatureFromMetadata = (metadata: ProviderMetadata | undefined, key: string): string | undefined => {
const provider = metadata?.[key]
if (!ProviderShared.isRecord(provider)) return undefined
return typeof provider.signature === "string" ? provider.signature : undefined
}
const redactedDataFromMetadata = (metadata: ProviderMetadata | undefined): string | undefined => {
const anthropic = metadata?.anthropic
if (!ProviderShared.isRecord(anthropic)) return undefined
return typeof anthropic.redactedData === "string" ? anthropic.redactedData : undefined
const redactedDataFromMetadata = (metadata: ProviderMetadata | undefined, key: string): string | undefined => {
const provider = metadata?.[key]
if (!ProviderShared.isRecord(provider)) return undefined
return typeof provider.redactedData === "string" ? provider.redactedData : undefined
}
const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition, inputSchema: JsonSchema): AnthropicTool => ({
@@ -511,13 +512,16 @@ const serverToolResultType = (name: string): AnthropicServerToolResultType | und
return undefined
}
const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult")(function* (part: ToolResultPart) {
const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult")(function* (
part: ToolResultPart,
providerMetadataKey: string,
) {
const wireType = serverToolResultType(part.name)
if (!wireType)
return yield* invalid(`Anthropic Messages does not know how to round-trip server tool result for ${part.name}`)
// Prefer the provider-owned replay payload; fall back to the result value for
// histories constructed directly from provider events.
const payload = part.providerMetadata?.anthropic?.["result"] ?? part.result.value
const payload = part.providerMetadata?.[providerMetadataKey]?.["result"] ?? part.result.value
return {
type: wireType,
tool_use_id: scrubToolCallID(part.id),
@@ -804,6 +808,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
breakpoints: Cache.Breakpoints,
) {
const messages: AnthropicMessage[] = []
const providerMetadataKey = request.model.route.providerMetadataKey ?? String(request.model.provider)
for (const [index, message] of request.messages.entries()) {
if (message.role === "system") {
@@ -849,8 +854,8 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
if (part.type === "reasoning") {
// A signature marks visible thinking; only signature-less parts carrying
// redactedData round-trip as opaque redacted_thinking blocks.
const signature = part.encrypted ?? signatureFromMetadata(part.providerMetadata)
const redactedData = redactedDataFromMetadata(part.providerMetadata)
const signature = part.encrypted ?? signatureFromMetadata(part.providerMetadata, providerMetadataKey)
const redactedData = redactedDataFromMetadata(part.providerMetadata, providerMetadataKey)
if (signature === undefined && redactedData !== undefined) {
content.push({ type: "redacted_thinking", data: redactedData })
continue
@@ -879,7 +884,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
continue
}
if (part.type === "tool-result" && part.providerExecuted) {
content.push(yield* lowerServerToolResult(part))
content.push(yield* lowerServerToolResult(part, providerMetadataKey))
continue
}
return yield* invalid(
@@ -1069,7 +1074,7 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
// inclusive `inputTokens` the rest of the contract expects. Extended
// thinking tokens are included in `output_tokens`; newer responses also
// expose that subset through `output_tokens_details.thinking_tokens`.
const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => {
const mapUsage = (usage: AnthropicUsage | undefined, providerMetadataKey: string): Usage | undefined => {
if (!usage) return undefined
const nonCached = usage.input_tokens ?? undefined
const cacheRead = usage.cache_read_input_tokens ?? undefined
@@ -1083,7 +1088,7 @@ const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => {
cacheWriteInputTokens: cacheWrite,
reasoningTokens: usage.output_tokens_details?.thinking_tokens,
totalTokens: ProviderShared.totalTokens(inputTokens, usage.output_tokens, undefined),
providerMetadata: { anthropic: usage },
providerMetadata: { [providerMetadataKey]: usage },
})
}
@@ -1092,7 +1097,7 @@ const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => {
// field prefers `right` when defined, falls back to `left`. `inputTokens` is
// recomputed from the merged breakdown so the inclusive total stays
// consistent with `nonCached + cacheRead + cacheWrite`.
const mergeUsage = (left: Usage | undefined, right: Usage | undefined) => {
const mergeUsage = (left: Usage | undefined, right: Usage | undefined, providerMetadataKey: string) => {
if (!left) return right
if (!right) return left
const nonCachedInputTokens = right.nonCachedInputTokens ?? left.nonCachedInputTokens
@@ -1110,7 +1115,9 @@ const mergeUsage = (left: Usage | undefined, right: Usage | undefined) => {
reasoningTokens,
totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined),
providerMetadata: {
anthropic: mergeJsonRecords(left.providerMetadata?.["anthropic"], right.providerMetadata?.["anthropic"]) ?? {},
[providerMetadataKey]:
mergeJsonRecords(left.providerMetadata?.[providerMetadataKey], right.providerMetadata?.[providerMetadataKey]) ??
{},
},
})
}
@@ -1128,7 +1135,7 @@ const SERVER_TOOL_RESULT_NAMES: Record<AnthropicServerToolResultType, string> =
const isServerToolResultType = (type: string): type is AnthropicServerToolResultType => type in SERVER_TOOL_RESULT_NAMES
const serverToolResultEvent = (block: AnthropicStreamBlock): LLMEvent | undefined => {
const serverToolResultEvent = (block: AnthropicStreamBlock, providerMetadataKey: string): LLMEvent | undefined => {
if (!block.type || !isServerToolResultType(block.type)) return undefined
const errorPayload =
typeof block.content === "object" && block.content !== null && "type" in block.content
@@ -1142,7 +1149,7 @@ const serverToolResultEvent = (block: AnthropicStreamBlock): LLMEvent | undefine
providerExecuted: true,
// The complete payload is irreducible provider replay state: subsequent
// stateless requests must round-trip the typed result block verbatim.
providerMetadata: anthropicMetadata({ blockType: block.type, result: block.content }),
providerMetadata: providerMetadata(providerMetadataKey, { blockType: block.type, result: block.content }),
})
}
@@ -1151,8 +1158,8 @@ type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
const NO_EVENTS: StepResult["1"] = []
const onMessageStart = (state: ParserState, event: AnthropicEvent): StepResult => {
const usage = mapUsage(event.message?.usage)
return [usage ? { ...state, usage: mergeUsage(state.usage, usage) } : state, NO_EVENTS]
const usage = mapUsage(event.message?.usage, state.providerMetadataKey)
return [usage ? { ...state, usage: mergeUsage(state.usage, usage, state.providerMetadataKey) } : state, NO_EVENTS]
}
const onContentBlockStart = (
@@ -1204,14 +1211,16 @@ const onContentBlockStart = (
if (block.type === "thinking" && block.thinking !== undefined) {
const events: LLMEvent[] = []
const id = `reasoning-${event.index ?? 0}`
const providerMetadata =
block.signature === undefined ? undefined : anthropicMetadata({ signature: block.signature })
const lifecycle = Lifecycle.reasoningStart(state.lifecycle, events, id, providerMetadata)
const metadata =
block.signature === undefined
? undefined
: providerMetadata(state.providerMetadataKey, { signature: block.signature })
const lifecycle = Lifecycle.reasoningStart(state.lifecycle, events, id, metadata)
return [
{
...state,
lifecycle: block.thinking
? Lifecycle.reasoningDelta(lifecycle, events, id, block.thinking, providerMetadata)
? Lifecycle.reasoningDelta(lifecycle, events, id, block.thinking, metadata)
: lifecycle,
reasoningSignatures:
event.index === undefined || block.signature === undefined
@@ -1234,14 +1243,14 @@ const onContentBlockStart = (
state.lifecycle,
events,
`reasoning-${event.index ?? 0}`,
anthropicMetadata({ redactedData: block.data }),
providerMetadata(state.providerMetadataKey, { redactedData: block.data }),
),
},
events,
]
}
const result = serverToolResultEvent(block)
const result = serverToolResultEvent(block, state.providerMetadataKey)
if (!result) return [state, NO_EVENTS]
const events: LLMEvent[] = []
return [{ ...state, lifecycle: Lifecycle.stepStart(state.lifecycle, events) }, [...events, result]]
@@ -1321,7 +1330,7 @@ const onContentBlockStop = Effect.fn("AnthropicMessages.onContentBlockStop")(fun
Lifecycle.textEnd(state.lifecycle, events, `text-${event.index}`),
events,
`reasoning-${event.index}`,
signature === undefined ? undefined : anthropicMetadata({ signature }),
signature === undefined ? undefined : providerMetadata(state.providerMetadataKey, { signature }),
)
events.push(...resultEvents)
const reasoningSignatures = { ...state.reasoningSignatures }
@@ -1333,7 +1342,7 @@ const onMessageDelta = (
state: ParserState,
event: AnthropicEvent & { readonly delta?: AnthropicStreamDelta },
): StepResult => {
const usage = mergeUsage(state.usage, mapUsage(event.usage))
const usage = mergeUsage(state.usage, mapUsage(event.usage, state.providerMetadataKey), state.providerMetadataKey)
return [
{
...state,
@@ -1346,7 +1355,7 @@ const onMessageDelta = (
providerMetadata:
event.delta?.stop_sequence === null || event.delta?.stop_sequence === undefined
? undefined
: anthropicMetadata({ stopSequence: event.delta.stop_sequence }),
: providerMetadata(state.providerMetadataKey, { stopSequence: event.delta.stop_sequence }),
},
},
NO_EVENTS,
@@ -1472,7 +1481,8 @@ export const protocol = Protocol.make({
},
stream: {
event: Protocol.jsonEvent(AnthropicEvent),
initial: () => ({
initial: (request) => ({
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
tools: ToolStream.empty<number>(),
reasoningSignatures: {},
lifecycle: Lifecycle.initial(),
+24 -25
View File
@@ -258,19 +258,21 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
tool: (name) => ({ tool: { name } }) as const,
})
const bedrockMetadata = (metadata: Record<string, unknown>): ProviderMetadata => ({ bedrock: metadata })
const providerMetadata = (key: string, metadata: Record<string, unknown>): ProviderMetadata => ({ [key]: metadata })
const reasoningSignature = (part: ReasoningPart) => {
const bedrock = part.providerMetadata?.bedrock
const reasoningSignature = (part: ReasoningPart, providerMetadataKey: string) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
return (
part.encrypted ??
(ProviderShared.isRecord(bedrock) && typeof bedrock.signature === "string" ? bedrock.signature : undefined)
(ProviderShared.isRecord(metadata) && typeof metadata.signature === "string" ? metadata.signature : undefined)
)
}
const reasoningRedactedData = (part: ReasoningPart) => {
const bedrock = part.providerMetadata?.bedrock
return ProviderShared.isRecord(bedrock) && typeof bedrock.redactedData === "string" ? bedrock.redactedData : undefined
const reasoningRedactedData = (part: ReasoningPart, providerMetadataKey: string) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
return ProviderShared.isRecord(metadata) && typeof metadata.redactedData === "string"
? metadata.redactedData
: undefined
}
const lowerToolCall = (part: ToolCallPart): BedrockToolUseBlock => ({
@@ -318,6 +320,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
breakpoints: BedrockCache.Breakpoints,
) {
const messages: BedrockMessage[] = []
const providerMetadataKey = request.model.route.providerMetadataKey ?? String(request.model.provider)
for (const message of request.messages) {
if (message.role === "system") {
@@ -365,8 +368,8 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
continue
}
if (part.type === "reasoning") {
const signature = reasoningSignature(part)
const redactedData = reasoningRedactedData(part)
const signature = reasoningSignature(part, providerMetadataKey)
const redactedData = reasoningRedactedData(part, providerMetadataKey)
if (signature === undefined && redactedData !== undefined) {
content.push({ reasoningContent: { redactedContent: redactedData } })
continue
@@ -466,7 +469,7 @@ const mapFinishReason = (reason: string): FinishReason => {
// AWS reports inputTokens separately from cache reads and writes.
// Bedrock does not break reasoning out of outputTokens for current models.
const mapUsage = (usage: BedrockUsageSchema | undefined): Usage | undefined => {
const mapUsage = (usage: BedrockUsageSchema | undefined, providerMetadataKey: string): Usage | undefined => {
if (!usage) return undefined
const inputTokens = ProviderShared.sumTokens(
usage.inputTokens,
@@ -480,11 +483,12 @@ const mapUsage = (usage: BedrockUsageSchema | undefined): Usage | undefined => {
cacheReadInputTokens: usage.cacheReadInputTokens,
cacheWriteInputTokens: usage.cacheWriteInputTokens,
totalTokens: ProviderShared.totalTokens(inputTokens, usage.outputTokens, usage.totalTokens),
providerMetadata: { bedrock: usage },
providerMetadata: { [providerMetadataKey]: usage },
})
}
interface ParserState {
readonly providerMetadataKey: string
readonly tools: ToolStream.State<number>
// Bedrock splits the finish into `messageStop` (carries `stopReason`) and
// `metadata` (carries usage). Hold the terminal event in state so `onHalt`
@@ -541,20 +545,14 @@ const step = (state: ParserState, event: BedrockEvent) =>
const reasoning = event.contentBlockDelta.delta.reasoningContent
const events: LLMEvent[] = []
const redactedData = reasoning.redactedContent ?? reasoning.data
const providerMetadata = reasoning.signature
? bedrockMetadata({ signature: reasoning.signature })
const metadata = reasoning.signature
? providerMetadata(state.providerMetadataKey, { signature: reasoning.signature })
: redactedData !== undefined
? bedrockMetadata({ redactedData })
? providerMetadata(state.providerMetadataKey, { redactedData })
: undefined
const lifecycle =
reasoning.text !== undefined || providerMetadata !== undefined
? Lifecycle.reasoningDelta(
state.lifecycle,
events,
`reasoning-${index}`,
reasoning.text ?? "",
providerMetadata,
)
reasoning.text !== undefined || metadata !== undefined
? Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${index}`, reasoning.text ?? "", metadata)
: state.lifecycle
return [
{
@@ -596,7 +594,7 @@ const step = (state: ParserState, event: BedrockEvent) =>
events,
`reasoning-${index}`,
state.reasoningSignatures[index]
? bedrockMetadata({ signature: state.reasoningSignatures[index] })
? providerMetadata(state.providerMetadataKey, { signature: state.reasoningSignatures[index] })
: undefined,
)
events.push(...resultEvents)
@@ -633,7 +631,7 @@ const step = (state: ParserState, event: BedrockEvent) =>
}
if (event.metadata) {
const usage = mapUsage(event.metadata.usage) ?? state.pendingFinish?.usage
const usage = mapUsage(event.metadata.usage, state.providerMetadataKey) ?? state.pendingFinish?.usage
return [
{
...state,
@@ -698,7 +696,8 @@ export const protocol = Protocol.make({
},
stream: {
event: BedrockEvent,
initial: () => ({
initial: (request) => ({
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
tools: ToolStream.empty<number>(),
pendingFinish: undefined,
hasToolCalls: false,
+36 -21
View File
@@ -229,6 +229,7 @@ type GeminiEvent = Schema.Schema.Type<typeof GeminiEvent>
interface ParserState {
readonly route: string
readonly providerMetadataKey: string
readonly finishReason?: string
readonly hasToolCalls: boolean
readonly promptFeedback?: GeminiPromptFeedback
@@ -285,22 +286,23 @@ const lowerUserPart = Effect.fn("Gemini.lowerUserPart")(function* (part: TextPar
return { inlineData: { mimeType: media.mime, data: media.base64 } }
})
const googleMetadata = (metadata: Record<string, unknown>): ProviderMetadata => ({ google: metadata })
const providerMetadata = (key: string, metadata: Record<string, unknown>): ProviderMetadata => ({ [key]: metadata })
const thoughtSignature = (providerMetadata: ProviderMetadata | undefined) => {
const google = providerMetadata?.google
return ProviderShared.isRecord(google) && typeof google.thoughtSignature === "string"
? google.thoughtSignature
const thoughtSignature = (metadata: ProviderMetadata | undefined, key: string) => {
const value = metadata?.[key]
return ProviderShared.isRecord(value) && typeof value.thoughtSignature === "string"
? value.thoughtSignature
: undefined
}
const lowerToolCall = (part: ToolCallPart, omitIds: boolean) => ({
const lowerToolCall = (part: ToolCallPart, omitIds: boolean, metadataKey: string) => ({
functionCall: { ...(omitIds ? {} : { id: part.id }), name: part.name, args: part.input },
thoughtSignature: thoughtSignature(part.providerMetadata),
thoughtSignature: thoughtSignature(part.providerMetadata, metadataKey),
})
const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMRequest) {
const contents: GeminiContent[] = []
const metadataKey = request.model.route.providerMetadataKey ?? String(request.model.provider)
const omitCallIds = omitsFunctionCallIds(request.model.id)
const legacyToolMedia = routesLegacyToolMedia(request.model.id)
let pendingMedia: GeminiInlineDataPart[] | undefined
@@ -342,15 +344,19 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
if (!ProviderShared.supportsContent(part, ["text", "reasoning", "tool-call"]))
return yield* ProviderShared.unsupportedContent("Gemini", "assistant", ["text", "reasoning", "tool-call"])
if (part.type === "text") {
parts.push({ text: part.text, thoughtSignature: thoughtSignature(part.providerMetadata) })
parts.push({ text: part.text, thoughtSignature: thoughtSignature(part.providerMetadata, metadataKey) })
continue
}
if (part.type === "reasoning") {
parts.push({ text: part.text, thought: true, thoughtSignature: thoughtSignature(part.providerMetadata) })
parts.push({
text: part.text,
thought: true,
thoughtSignature: thoughtSignature(part.providerMetadata, metadataKey),
})
continue
}
if (part.type === "tool-call") {
const lowered = lowerToolCall(part, omitCallIds)
const lowered = lowerToolCall(part, omitCallIds, metadataKey)
const signature = lowered.thoughtSignature
parts.push({
...lowered,
@@ -498,7 +504,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
// `cachedContentTokenCount` subset. `candidatesTokenCount` is *exclusive*
// of `thoughtsTokenCount` — visible-only, not a total — so we sum the two
// to produce the inclusive `outputTokens` the rest of the contract expects.
const mapUsage = (usage: GeminiUsage | undefined) => {
const mapUsage = (usage: GeminiUsage | undefined, metadataKey: string) => {
if (!usage) return undefined
// Explicit provider nulls decode as `null`; normalize to `undefined` so the
// token arithmetic below treats them like absent counts.
@@ -519,7 +525,7 @@ const mapUsage = (usage: GeminiUsage | undefined) => {
cacheReadInputTokens: cached,
reasoningTokens: thoughts,
totalTokens: ProviderShared.totalTokens(promptTokens, outputTokens, usage.totalTokenCount ?? undefined),
providerMetadata: { google: usage },
providerMetadata: providerMetadata(metadataKey, usage),
})
}
@@ -567,14 +573,14 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
lifecycle,
events,
"reasoning-0",
googleMetadata({ thoughtSignature: state.reasoningSignature }),
providerMetadata(state.providerMetadataKey, { thoughtSignature: state.reasoningSignature }),
)
if (state.textSignature !== undefined)
lifecycle = Lifecycle.textEnd(
lifecycle,
events,
"text-0",
googleMetadata({ thoughtSignature: state.textSignature }),
providerMetadata(state.providerMetadataKey, { thoughtSignature: state.textSignature }),
)
Lifecycle.finish(lifecycle, events, {
reason: {
@@ -584,7 +590,9 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
},
usage: state.usage,
providerMetadata:
state.promptFeedback === undefined ? undefined : googleMetadata({ promptFeedback: state.promptFeedback }),
state.promptFeedback === undefined
? undefined
: providerMetadata(state.providerMetadataKey, { promptFeedback: state.promptFeedback }),
})
return events
}
@@ -593,7 +601,9 @@ const step = (state: ParserState, event: GeminiEvent) => {
const nextState = {
...state,
promptFeedback: event.promptFeedback ?? state.promptFeedback,
usage: event.usageMetadata ? (mapUsage(event.usageMetadata) ?? state.usage) : state.usage,
usage: event.usageMetadata
? (mapUsage(event.usageMetadata, state.providerMetadataKey) ?? state.usage)
: state.usage,
}
const candidate = event.candidates?.[0]
if (!candidate?.content)
@@ -637,7 +647,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
events,
"reasoning-0",
part.text,
signature ? googleMetadata({ thoughtSignature: signature }) : undefined,
signature ? providerMetadata(state.providerMetadataKey, { thoughtSignature: signature }) : undefined,
)
continue
}
@@ -645,14 +655,16 @@ const step = (state: ParserState, event: GeminiEvent) => {
lifecycle,
events,
"reasoning-0",
reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined,
reasoningSignature
? providerMetadata(state.providerMetadataKey, { thoughtSignature: reasoningSignature })
: undefined,
)
lifecycle = Lifecycle.textDelta(
lifecycle,
events,
"text-0",
part.text,
textSignature ? googleMetadata({ thoughtSignature: textSignature }) : undefined,
textSignature ? providerMetadata(state.providerMetadataKey, { thoughtSignature: textSignature }) : undefined,
)
textSignature = undefined
continue
@@ -672,7 +684,9 @@ const step = (state: ParserState, event: GeminiEvent) => {
lifecycle,
events,
"reasoning-0",
reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined,
reasoningSignature
? providerMetadata(state.providerMetadataKey, { thoughtSignature: reasoningSignature })
: undefined,
)
lifecycle = Lifecycle.stepStart(lifecycle, events)
events.push(
@@ -681,7 +695,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
name: part.functionCall.name,
input,
providerMetadata: part.thoughtSignature
? googleMetadata({ thoughtSignature: part.thoughtSignature })
? providerMetadata(state.providerMetadataKey, { thoughtSignature: part.thoughtSignature })
: undefined,
}),
)
@@ -720,6 +734,7 @@ export const protocol = Protocol.make({
event: Protocol.jsonEvent(GeminiEvent),
initial: (request) => ({
route: `${request.model.provider}/${request.model.route.id}`,
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
hasToolCalls: false,
lifecycle: Lifecycle.initial(),
}),
+49 -19
View File
@@ -253,6 +253,7 @@ interface PendingToolDelta {
}
export interface ParserState {
readonly providerMetadataKey: string
readonly tools: ToolStream.State<number>
readonly pendingTools: Partial<Record<number, PendingToolDelta>>
readonly toolCallEvents: ReadonlyArray<LLMEvent>
@@ -324,17 +325,18 @@ const lowerMedia = Effect.fn("OpenAIChat.lowerMedia")(function* (part: MediaPart
const openAICompatibleReasoningContent = (native: unknown) =>
isRecord(native) && typeof native.reasoning_content === "string" ? native.reasoning_content : undefined
const reasoningField = (part: ReasoningPart) => {
const field = part.providerMetadata?.openai?.reasoningField
const reasoningField = (part: ReasoningPart, providerMetadataKey: string) => {
const field = part.providerMetadata?.[providerMetadataKey]?.reasoningField
return typeof field === "string" ? field : undefined
}
const reasoningDetails = (parts: ReadonlyArray<ReasoningPart>, native: unknown) => {
const reasoningDetails = (parts: ReadonlyArray<ReasoningPart>, native: unknown, providerMetadataKey: string) => {
const observed = parts.flatMap((part) => {
const details = part.providerMetadata?.openai?.reasoningDetails
const details = part.providerMetadata?.[providerMetadataKey]?.reasoningDetails
return Array.isArray(details) ? details : []
})
if (parts.some((part) => Array.isArray(part.providerMetadata?.openai?.reasoningDetails))) return observed
if (parts.some((part) => Array.isArray(part.providerMetadata?.[providerMetadataKey]?.reasoningDetails)))
return observed
if (isRecord(native) && Array.isArray(native.reasoning_details)) return native.reasoning_details
}
@@ -366,7 +368,7 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
message: OpenAIChatRequestMessage,
configuredField: string | undefined,
requireReasoning: boolean,
options: LoweringOptions,
options: LoweringOptions & { readonly providerMetadataKey: string },
) {
const content: TextPart[] = []
const reasoning: ReasoningPart[] = []
@@ -388,10 +390,14 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
}
}
const text = reasoning.map((part) => part.text).join("")
const details = reasoningDetails(reasoning, message.native?.openaiCompatible)
const observedField = reasoning.map(reasoningField).find((value) => value !== undefined)
const details = reasoningDetails(reasoning, message.native?.openaiCompatible, options.providerMetadataKey)
const observedField = reasoning
.map((part) => reasoningField(part, options.providerMetadataKey))
.find((value) => value !== undefined)
const nativeReasoning = openAICompatibleReasoningContent(message.native?.openaiCompatible)
const fullyStructured = reasoning.every((part) => Array.isArray(part.providerMetadata?.openai?.reasoningDetails))
const fullyStructured = reasoning.every((part) =>
Array.isArray(part.providerMetadata?.[options.providerMetadataKey]?.reasoningDetails),
)
const field = (() => {
if (configuredField !== undefined && (requireReasoning || reasoning.length > 0 || nativeReasoning !== undefined))
return configuredField
@@ -459,7 +465,7 @@ const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (
message: OpenAIChatRequestMessage,
reasoningField: string | undefined,
requireReasoning: boolean,
options: LoweringOptions,
options: LoweringOptions & { readonly providerMetadataKey: string },
) {
if (message.role === "user") return [yield* lowerUserMessage(message, options)]
if (message.role === "assistant")
@@ -495,6 +501,7 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
const mistral = ["mistral", "devstral", "codestral", "pixtral", "mixtral"].some((family) => modelID.includes(family))
const lowering = {
...options,
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
toolCallID: (id: string) => {
if (mistral)
return id
@@ -820,7 +827,7 @@ const mapFinishReason = Effect.fn("OpenAIChat.mapFinishReason")(function* (event
// Providers differ on cache-hit location: OpenAI uses
// `prompt_tokens_details.cached_tokens`, DeepSeek uses
// `prompt_cache_hit_tokens`, and Zai uses top-level `cached_tokens`.
const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
const mapUsage = (usage: OpenAIChatEvent["usage"], providerMetadataKey: string): Usage | undefined => {
if (!usage) return undefined
const input = usage.prompt_tokens ?? undefined
const output = usage.completion_tokens ?? undefined
@@ -839,7 +846,7 @@ const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
cacheWriteInputTokens: cacheWrite,
reasoningTokens: reasoning,
totalTokens: ProviderShared.totalTokens(input, output, usage.total_tokens ?? undefined),
providerMetadata: { openai: usage },
providerMetadata: { [providerMetadataKey]: usage },
})
}
@@ -913,8 +920,12 @@ const conflictingReasoningTextDetails = (previous: Record<string, unknown>, curr
const conflictingDetailValue = (previous: unknown, current: unknown) =>
previous !== undefined && previous !== null && current !== undefined && current !== null && previous !== current
const reasoningMetadata = (field: ParserState["reasoningField"], details?: ReadonlyArray<unknown>) => ({
openai: {
const reasoningMetadata = (
providerMetadataKey: string,
field: ParserState["reasoningField"],
details?: ReadonlyArray<unknown>,
) => ({
[providerMetadataKey]: {
...(field ? { reasoningField: field } : {}),
...(details ? { reasoningDetails: details } : {}),
},
@@ -941,7 +952,10 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
// Moonshot (and a few other OpenAI-compatible providers) attach usage to
// `choice.usage` instead of the top-level `usage` field.
const choiceUsage = (choice as unknown as { usage?: OpenAIChatEvent["usage"] })?.usage
const usage = mapUsage(event.usage) ?? (choiceUsage ? mapUsage(choiceUsage) : undefined) ?? state.usage
const usage =
mapUsage(event.usage, state.providerMetadataKey) ??
(choiceUsage ? mapUsage(choiceUsage, state.providerMetadataKey) : undefined) ??
state.usage
const rawFinishReason = choice?.finish_reason
const finishReason = rawFinishReason
? {
@@ -979,7 +993,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
const detailDelta = Array.isArray(delta?.reasoning_details) ? delta.reasoning_details : undefined
if (detailDelta !== undefined) appendReasoningDetails(state.reasoningDetails, detailDelta)
const reasoningDetailsObserved = state.reasoningDetailsObserved || detailDelta !== undefined
const deltaMetadata = reasoningMetadata(reasoningField)
const deltaMetadata = reasoningMetadata(state.providerMetadataKey, reasoningField)
const text = detailDelta?.length ? (detailText(detailDelta) ?? reasoning?.text) : reasoning?.text
if (text !== undefined) lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", text, deltaMetadata)
else if (
@@ -995,7 +1009,11 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
lifecycle,
events,
"reasoning-0",
reasoningMetadata(reasoningField, reasoningDetailsObserved ? state.reasoningDetails : undefined),
reasoningMetadata(
state.providerMetadataKey,
reasoningField,
reasoningDetailsObserved ? state.reasoningDetails : undefined,
),
)
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
}
@@ -1005,7 +1023,11 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
lifecycle,
events,
"reasoning-0",
reasoningMetadata(reasoningField, reasoningDetailsObserved ? state.reasoningDetails : undefined),
reasoningMetadata(
state.providerMetadataKey,
reasoningField,
reasoningDetailsObserved ? state.reasoningDetails : undefined,
),
)
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.refusal)
}
@@ -1066,6 +1088,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
return [
{
providerMetadataKey: state.providerMetadataKey,
tools: finished?.tools ?? tools,
pendingTools,
toolCallEvents: finished?.events ?? state.toolCallEvents,
@@ -1109,12 +1132,18 @@ const finishEvents = Effect.fn("OpenAIChat.finishEvents")(function* (state: Pars
}
: { normalized: hasToolCalls ? ("tool-calls" as const) : ("stop" as const) }
const metadata = reasoningMetadata(
state.providerMetadataKey,
state.reasoningField,
state.reasoningDetailsObserved ? state.reasoningDetails : undefined,
)
const started =
state.reasoningDetailsObserved && !state.reasoningEmitted
? Lifecycle.reasoningStart(state.lifecycle, events, "reasoning-0", reasoningMetadata(state.reasoningField))
? Lifecycle.reasoningStart(
state.lifecycle,
events,
"reasoning-0",
reasoningMetadata(state.providerMetadataKey, state.reasoningField),
)
: state.lifecycle
const ended = Lifecycle.reasoningEnd(started, events, "reasoning-0", metadata)
const lifecycle = toolCallEvents.length ? Lifecycle.stepStart(ended, events) : ended
@@ -1141,6 +1170,7 @@ export const protocol = Protocol.make({
stream: {
event: Protocol.jsonEvent(OpenAIChatEvent),
initial: (request) => ({
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
tools: ToolStream.empty<number>(),
pendingTools: {},
toolCallEvents: [],
@@ -23,13 +23,14 @@ export interface Settings extends ProviderPackage.Settings {
readonly baseURL?: string
readonly credentials?: Credentials
readonly region?: string
readonly topP?: number
readonly providerOptions?: OpenAIProviderOptionsInput
}
const responsesRoute = Route.make({
id: "bedrock-mantle-responses",
provider: id,
providerMetadataKey: OpenAIResponses.route.providerMetadataKey,
providerMetadataKey: "mantle",
protocol: OpenAIResponses.protocol,
endpoint: OpenAIResponses.route.endpoint,
auth: OpenAIResponses.route.auth,
@@ -40,6 +41,7 @@ const responsesRoute = Route.make({
const chatRoute = OpenAIChat.route.with({
id: "bedrock-mantle-chat",
provider: id,
providerMetadataKey: "mantle",
})
export const routes = [responsesRoute, chatRoute]
@@ -76,7 +78,7 @@ export const configure = (input: Config = {}) => {
return {
id,
model: chat,
model: responses,
chat,
responses,
configure,
@@ -94,6 +96,7 @@ const config = (settings: Settings): Config => {
apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey,
baseURL: settings.baseURL,
credentials: settings.credentials,
generation: settings.topP === undefined ? undefined : { topP: settings.topP },
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
@@ -109,4 +112,4 @@ export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProvider
modelID,
settings,
) => configure(config(settings)).responses(modelID)
export const model = chatModel
export const model = responsesModel
@@ -35,6 +35,7 @@ const configuredRoute = (input: Config) => {
return BedrockConverse.route.with({
...rest,
provider: id,
providerMetadataKey: "bedrock",
endpoint: { baseURL: baseURL ?? bedrockBaseURL(resolvedRegion) },
auth: apiKey === undefined ? BedrockConverse.sigV4Auth(credentials) : Auth.bearer(apiKey),
})
@@ -1,2 +1,2 @@
export { chatModel as model } from "../amazon-bedrock-mantle.js"
export { responsesModel as model } from "../amazon-bedrock-mantle.js"
export type { Settings } from "../amazon-bedrock-mantle.js"
@@ -27,6 +27,7 @@ export interface Settings extends ProviderPackage.Settings {
const route = OpenAICompatibleChat.route.with({
id: "google-vertex-chat",
provider: id,
providerMetadataKey: "vertex",
})
export const routes = [route]
@@ -27,6 +27,7 @@ export interface Settings extends ProviderPackage.Settings {
const route = OpenAICompatibleResponses.route.with({
id: "google-vertex-responses",
provider: id,
providerMetadataKey: "vertex",
providerOptions: { store: false },
})
+1 -1
View File
@@ -68,7 +68,7 @@ const protocol = {
const route = Route.make({
id: "google-vertex-gemini",
provider: id,
providerMetadataKey: "google",
providerMetadataKey: "vertex",
protocol,
endpoint: Endpoint.path(({ request }) => {
const model = String(request.model.id)
+1
View File
@@ -164,6 +164,7 @@ const bodyOptions = (input: unknown) => {
export const route = Route.make({
id: ADAPTER,
provider: profile.provider,
providerMetadataKey: "openrouter",
protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL: profile.baseURL }),
framing: Framing.sse,
+7 -1
View File
@@ -89,6 +89,7 @@ export interface RouteDefaultsInput {
export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
readonly id?: string
readonly provider?: string | ProviderID
readonly providerMetadataKey?: string
readonly auth?: Auth.Definition
readonly transport?: Transport<Body, Prepared, unknown>
readonly endpoint?: EndpointPatch<Body>
@@ -289,11 +290,16 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
defaults: routeInput.defaults ?? {},
body: protocol.body,
with: (patch: RoutePatch<Body, Prepared>) => {
const { id, provider, auth, transport, endpoint, ...defaults } = patch
const { id, provider, providerMetadataKey, auth, transport, endpoint, ...defaults } = patch
return build({
...routeInput,
id: id ?? routeInput.id,
provider: provider ?? routeInput.provider,
providerMetadataKey:
providerMetadataKey ??
(provider !== undefined && String(provider) !== String(routeInput.provider)
? String(provider)
: routeInput.providerMetadataKey),
auth: auth ?? routeInput.auth,
endpoint: endpoint ? Endpoint.merge(routeInput.endpoint, endpoint) : routeInput.endpoint,
transport: (transport as Transport<Body, Prepared, Frame> | undefined) ?? routeInput.transport,
-15
View File
@@ -40,17 +40,6 @@ const headerDetails = (headers: Headers.Headers) =>
const normalizedHeaders = (headers: Headers.Headers) =>
Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]))
const requestId = (headers: Record<string, string>) => {
return (
headers["x-request-id"] ??
headers["request-id"] ??
headers["x-amzn-requestid"] ??
headers["x-amz-request-id"] ??
headers["x-goog-request-id"] ??
headers["cf-ray"]
)
}
const retryAfterMs = (headers: Record<string, string>) => {
const millis = Number(headers["retry-after-ms"])
if (Number.isFinite(millis)) return Math.max(0, millis)
@@ -147,14 +136,12 @@ const responseHttp = (input: {
readonly request: HttpClientRequest.HttpClientRequest
readonly response: HttpClientResponse.HttpClientResponse
readonly body: ReturnType<typeof responseBody>
readonly requestId?: string | undefined
readonly rateLimit?: HttpRateLimitDetails | undefined
}) =>
new HttpContext({
request: requestDetails(input.request),
response: responseDetails(input.response),
...input.body,
requestId: input.requestId,
rateLimit: input.rateLimit,
})
@@ -179,7 +166,6 @@ const statusError =
request,
response,
body: details,
requestId: requestId(headers),
rateLimit,
}),
}),
@@ -216,7 +202,6 @@ export const classifyHttpFailure = (input: {
? undefined
: new HttpResponseDetails({ status: input.status, headers: headerDetails(Headers.fromInput(headers)) }),
...details,
requestId: requestId(headers),
rateLimit,
}),
})
-1
View File
@@ -29,7 +29,6 @@ export class HttpContext extends Schema.Class<HttpContext>("AI.HttpContext")({
response: Schema.optional(HttpResponseDetails),
body: Schema.optional(Schema.String),
bodyTruncated: Schema.optional(Schema.Boolean),
requestId: Schema.optional(Schema.String),
rateLimit: Schema.optional(HttpRateLimitDetails),
}) {}
-1
View File
@@ -327,7 +327,6 @@ describe("RequestExecutor", () => {
retryAfterMs: 0,
rateLimit: { retryAfterMs: 0 },
http: {
requestId: "req_123",
request: {
method: "POST",
url: "https://provider.test/v1/chat?api_key=secret&key=secret&debug=1",
@@ -2,7 +2,7 @@ import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { CacheHint, LLM, AIError, LLMRequest, Message, ToolCallPart, ToolDefinition, Usage } from "../../src/index.js"
import { Auth, LLMClient } from "../../src/route.js"
import { Auth, Endpoint, LLMClient, Route } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import * as AnthropicMessages from "../../src/protocols/anthropic-messages.js"
import { GoogleVertexMessages } from "../../src/providers.js"
@@ -810,6 +810,99 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("round-trips compatible provider metadata in its own namespace", () =>
Effect.gen(function* () {
const compatible = Route.make({
id: "custom-anthropic-messages",
provider: "custom-anthropic",
protocol: AnthropicMessages.protocol,
endpoint: Endpoint.path("/messages", { baseURL: "https://compatible.test/v1" }),
auth: Auth.header("x-api-key", "test"),
framing: AnthropicMessages.framing,
}).model({ id: "custom-model" })
const result = [
{
type: "web_search_result",
url: "https://example.com",
citations: [{ type: "web_search_result_location", cited_text: "Example" }],
},
]
const response = yield* LLMClient.generate(LLM.request({ model: compatible, prompt: "Search." })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5, custom_start: true } } },
{ type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "Thinking." } },
{ type: "content_block_delta", index: 0, delta: { type: "signature_delta", signature: "custom_sig" } },
{ type: "content_block_stop", index: 0 },
{
type: "content_block_start",
index: 1,
content_block: { type: "redacted_thinking", data: "custom_redacted" },
},
{ type: "content_block_stop", index: 1 },
{
type: "content_block_start",
index: 2,
content_block: {
type: "server_tool_use",
id: "custom_tool",
name: "web_search",
input: { query: "example" },
},
},
{ type: "content_block_stop", index: 2 },
{
type: "content_block_start",
index: 3,
content_block: { type: "web_search_tool_result", tool_use_id: "custom_tool", content: result },
},
{ type: "content_block_stop", index: 3 },
{
type: "message_delta",
delta: { stop_reason: "end_turn", stop_sequence: "custom_stop" },
usage: { output_tokens: 2, custom_terminal: true },
},
{ type: "message_stop" },
),
),
),
)
expect(response.message.content).toMatchObject([
{ type: "reasoning", text: "Thinking.", providerMetadata: { "custom-anthropic": { signature: "custom_sig" } } },
{ type: "reasoning", text: "", providerMetadata: { "custom-anthropic": { redactedData: "custom_redacted" } } },
{ type: "tool-call", id: "custom_tool", providerExecuted: true },
{
type: "tool-result",
providerExecuted: true,
providerMetadata: { "custom-anthropic": { blockType: "web_search_tool_result", result } },
},
])
expect(response.usage?.providerMetadata).toEqual({
"custom-anthropic": { input_tokens: 5, custom_start: true, output_tokens: 2, custom_terminal: true },
})
expect(response.events.at(-1)).toMatchObject({
providerMetadata: { "custom-anthropic": { stopSequence: "custom_stop" } },
})
const prepared = yield* compileRequest(
LLM.request({ model: compatible, messages: [response.message], cache: "none" }),
)
expect(prepared.body.messages).toEqual([
{
role: "assistant",
content: [
{ type: "thinking", thinking: "Thinking.", signature: "custom_sig" },
{ type: "redacted_thinking", data: "custom_redacted" },
{ type: "server_tool_use", id: "custom_tool", name: "web_search", input: { query: "example" } },
{ type: "web_search_tool_result", tool_use_id: "custom_tool", content: result },
],
},
])
}),
)
it.effect("parses text, reasoning, and usage stream fixtures", () =>
Effect.gen(function* () {
const body = sseEvents(
@@ -569,6 +569,57 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("round-trips reassigned provider reasoning and usage metadata in its own namespace", () =>
Effect.gen(function* () {
const compatible = model.route.with({ provider: "custom-bedrock" }).model({ id: model.id })
const redactedData = "cmVkYWN0ZWQtdGhpbmtpbmc="
const response = yield* LLMClient.generate(LLMRequest.update(baseRequest, { model: compatible })).pipe(
Effect.provide(
fixedBytes(
eventStreamBody(
["messageStart", { role: "assistant" }],
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { text: "Let me think." } } }],
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { signature: "custom_sig" } } }],
["contentBlockStop", { contentBlockIndex: 0 }],
[
"contentBlockDelta",
{ contentBlockIndex: 1, delta: { reasoningContent: { redactedContent: redactedData } } },
],
["contentBlockStop", { contentBlockIndex: 1 }],
["messageStop", { stopReason: "end_turn" }],
["metadata", { usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 } }],
),
),
),
)
expect(response.message.content).toEqual([
{
type: "reasoning",
text: "Let me think.",
providerMetadata: { "custom-bedrock": { signature: "custom_sig" } },
},
{ type: "reasoning", text: "", providerMetadata: { "custom-bedrock": { redactedData } } },
])
expect(response.usage?.providerMetadata).toEqual({
"custom-bedrock": { inputTokens: 5, outputTokens: 2, totalTokens: 7 },
})
const prepared = yield* compileRequest(
LLM.request({ model: compatible, messages: [response.message], cache: "none" }),
)
expect(prepared.body.messages).toEqual([
{
role: "assistant",
content: [
{ reasoningContent: { reasoningText: { text: "Let me think.", signature: "custom_sig" } } },
{ reasoningContent: { redactedContent: redactedData } },
],
},
])
}),
)
it.effect("preserves reasoning signatures when contentBlockStop is missing", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(baseRequest).pipe(
@@ -3,6 +3,7 @@ import { Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, Message } from "../../src/index.js"
import { AmazonBedrockMantle } from "../../src/providers.js"
import { model } from "../../src/providers/amazon-bedrock/mantle.js"
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
import { compileRequest, LLMClient } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
@@ -17,13 +18,16 @@ const credentials = {
}
describe("Amazon Bedrock Mantle provider", () => {
it.effect("uses Chat by default and exposes Responses", () =>
it.effect("uses Responses by default and exposes Chat explicitly", () =>
Effect.gen(function* () {
const provider = AmazonBedrockMantle.configure({ credentials })
expect(provider.responses("openai.gpt-oss-120b").route.transport).toBe(OpenAIResponses.httpTransport)
const chat = yield* compileRequest(LLM.request({ model: provider.model("openai.gpt-oss-120b"), prompt: "Hi" }))
expect(provider.model).toBe(provider.responses)
expect(AmazonBedrockMantle.model).toBe(AmazonBedrockMantle.responsesModel)
expect(model).toBe(AmazonBedrockMantle.responsesModel)
expect(provider.model("openai.gpt-oss-120b").route.transport).toBe(OpenAIResponses.httpTransport)
const chat = yield* compileRequest(LLM.request({ model: provider.chat("openai.gpt-oss-120b"), prompt: "Hi" }))
const responses = yield* compileRequest(
LLM.request({ model: provider.responses("openai.gpt-oss-120b"), prompt: "Hi" }),
LLM.request({ model: provider.model("openai.gpt-oss-120b"), prompt: "Hi" }),
)
expect(chat).toMatchObject({
@@ -36,6 +40,23 @@ describe("Amazon Bedrock Mantle provider", () => {
protocol: "openai-responses",
body: { model: "openai.gpt-oss-120b", store: false },
})
expect(provider.model("openai.gpt-oss-120b").route.providerMetadataKey).toBe("mantle")
expect(provider.chat("openai.gpt-oss-120b").route.providerMetadataKey).toBe("mantle")
}),
)
it.effect("preserves configured top-p generation defaults for Chat and Responses", () =>
Effect.gen(function* () {
const settings = { apiKey: "test-key", topP: 0.8 }
const chat = yield* compileRequest(
LLM.request({ model: AmazonBedrockMantle.chatModel("openai.gpt-oss-safeguard-20b", settings), prompt: "Hi" }),
)
const responses = yield* compileRequest(
LLM.request({ model: AmazonBedrockMantle.responsesModel("openai.gpt-oss-120b", settings), prompt: "Hi" }),
)
expect(chat.body.top_p).toBe(0.8)
expect(responses.body.top_p).toBe(0.8)
}),
)
@@ -107,6 +128,9 @@ describe("Amazon Bedrock Mantle provider", () => {
LLM.request({ model, messages: [response.message, Message.user("Continue.")] }),
)
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
mantle: { itemId: "msg_95d4d0af4350432a", reasoningEncryptedContent: "mantle-state" },
})
expect(prepared.body.input).toEqual([
{
type: "reasoning",
+1 -1
View File
@@ -126,7 +126,7 @@ describe("Cloudflare", () => {
expect(response.reasoning).toBe("Thinking")
expect(response.events.filter(LLMEvent.is.reasoningDelta)).toHaveLength(2)
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningField: "reasoning", reasoningDetails: merged },
"cloudflare-ai-gateway": { reasoningField: "reasoning", reasoningDetails: merged },
})
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
@@ -6,7 +6,7 @@ import { GoogleVertex, GoogleVertexChat, GoogleVertexMessages, GoogleVertexRespo
import { LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { dynamicResponse } from "../lib/http.js"
import { dynamicResponse, fixedResponse } from "../lib/http.js"
import { deltaChunk, finishChunk } from "../lib/openai-chunks.js"
import { sseEvents } from "../lib/sse.js"
@@ -89,7 +89,7 @@ describe("Google Vertex providers", () => {
id: "call_1",
name: "lookup",
input: { query: "weather" },
providerMetadata: { google: { functionCallId: "provider_call_1" } },
providerMetadata: { vertex: { functionCallId: "provider_call_1" } },
}),
]),
Message.tool({
@@ -97,7 +97,7 @@ describe("Google Vertex providers", () => {
name: "lookup",
result: "sunny",
resultType: "text",
providerMetadata: { google: { functionCallId: "provider_call_1" } },
providerMetadata: { vertex: { functionCallId: "provider_call_1" } },
}),
],
}),
@@ -122,6 +122,91 @@ describe("Google Vertex providers", () => {
}),
)
it.effect("round-trips Vertex Gemini metadata through signed content, tool calls, and usage", () =>
Effect.gen(function* () {
const model = GoogleVertex.configure({
accessToken: "vertex-token",
project: "vertex-project",
}).model("gemini-3.5-flash")
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Check the weather." })).pipe(
Effect.provide(
fixedResponse(
sseEvents({
candidates: [
{
content: {
role: "model",
parts: [
{ text: "Thinking.", thought: true, thoughtSignature: "reasoning_sig" },
{ text: "Checking.", thoughtSignature: "text_sig" },
{
functionCall: { id: "provider_call_1", name: "lookup", args: { query: "weather" } },
thoughtSignature: "tool_sig",
},
],
},
finishReason: "STOP",
},
],
promptFeedback: { blockReasonMessage: "Reviewed" },
usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 2, thoughtsTokenCount: 1 },
}),
),
),
)
const reasoning = response.events.find((event) => event.type === "reasoning-end")
const text = response.events.find((event) => event.type === "text-delta")
const toolCall = response.toolCalls[0]
expect(reasoning?.providerMetadata).toEqual({ vertex: { thoughtSignature: "reasoning_sig" } })
expect(text?.providerMetadata).toEqual({ vertex: { thoughtSignature: "text_sig" } })
expect(toolCall).toMatchObject({
id: "provider_call_1",
providerMetadata: { vertex: { thoughtSignature: "tool_sig" } },
})
expect(response.usage?.providerMetadata).toEqual({
vertex: { promptTokenCount: 5, candidatesTokenCount: 2, thoughtsTokenCount: 1 },
})
expect(response.events.at(-1)?.providerMetadata).toEqual({
vertex: { promptFeedback: { blockReasonMessage: "Reviewed" } },
})
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([
{ type: "reasoning", text: "Thinking.", providerMetadata: reasoning?.providerMetadata },
{ type: "text", text: "Checking.", providerMetadata: text?.providerMetadata },
ToolCallPart.make({
id: toolCall.id,
name: toolCall.name,
input: toolCall.input,
providerMetadata: toolCall.providerMetadata,
}),
]),
Message.tool({ id: toolCall.id, name: toolCall.name, result: "sunny", resultType: "text" }),
],
}),
)
expect(prepared.body.contents).toEqual([
{
role: "model",
parts: [
{ text: "Thinking.", thought: true, thoughtSignature: "reasoning_sig" },
{ text: "Checking.", thoughtSignature: "text_sig" },
{ functionCall: { name: "lookup", args: { query: "weather" } }, thoughtSignature: "tool_sig" },
],
},
{
role: "user",
parts: [{ functionResponse: { name: "lookup", response: { name: "lookup", content: "sunny" } } }],
},
])
}),
)
it.effect("projects Anthropic Messages onto the Vertex raw-predict API", () =>
Effect.gen(function* () {
const model = GoogleVertexMessages.configure({
@@ -2,13 +2,80 @@ import { describe, expect } from "bun:test"
import { ConfigProvider, Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, Message, ToolDefinition } from "../../src/index.js"
import { Cerebras, DeepInfra, Groq, TogetherAI } from "../../src/providers/index.js"
import {
AmazonBedrock,
AmazonBedrockMantle,
Anthropic,
AnthropicCompatible,
Azure,
Cerebras,
CloudflareAIGateway,
CloudflareWorkersAI,
DeepInfra,
Google,
GoogleVertex,
GoogleVertexChat,
GoogleVertexMessages,
GoogleVertexResponses,
Groq,
OpenAI,
OpenAICompatible,
OpenAICompatibleResponses,
OpenRouter,
TogetherAI,
XAI,
} from "../../src/providers/index.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { dynamicResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
describe("native OpenAI-compatible providers", () => {
it.effect("assigns provider-owned metadata namespaces across native routes", () =>
Effect.gen(function* () {
const vertex = { project: "project", accessToken: "token" }
const providers = [
[OpenAI.configure({ apiKey: "test" }).chat("model"), "openai"],
[OpenAI.configure({ apiKey: "test" }).responses("model"), "openai"],
[Azure.configure({ resourceName: "resource", apiKey: "test" }).chat("model"), "azure"],
[Azure.configure({ resourceName: "resource", apiKey: "test" }).responses("model"), "azure"],
[AmazonBedrock.configure({ apiKey: "test" }).model("model"), "bedrock"],
[AmazonBedrockMantle.configure({ apiKey: "test" }).chat("model"), "mantle"],
[AmazonBedrockMantle.configure({ apiKey: "test" }).responses("model"), "mantle"],
[Google.configure({ apiKey: "test" }).model("model"), "google"],
[GoogleVertex.configure(vertex).model("model"), "vertex"],
[GoogleVertexChat.configure(vertex).model("model"), "vertex"],
[GoogleVertexResponses.configure(vertex).model("model"), "vertex"],
[GoogleVertexMessages.configure(vertex).model("model"), "anthropic"],
[Anthropic.configure({ apiKey: "test" }).model("model"), "anthropic"],
[
AnthropicCompatible.configure({ baseURL: "https://example.test/v1", provider: "minimax" }).model("model"),
"minimax",
],
[
OpenAICompatible.configure({ baseURL: "https://example.test/v1", provider: "custom" }).model("model"),
"custom",
],
[
OpenAICompatibleResponses.configure({ baseURL: "https://example.test/v1", provider: "custom" }).model(
"model",
),
"custom",
],
[Cerebras.configure({ apiKey: "test" }).model("model"), "cerebras"],
[DeepInfra.configure({ apiKey: "test" }).model("model"), "deepinfra"],
[TogetherAI.configure({ apiKey: "test" }).model("model"), "togetherai"],
[CloudflareAIGateway.configure({ accountId: "account" }).model("model"), "cloudflare-ai-gateway"],
[CloudflareWorkersAI.configure({ accountId: "account" }).model("model"), "cloudflare-workers-ai"],
[OpenRouter.configure({ apiKey: "test" }).model("model"), "openrouter"],
[XAI.configure({ apiKey: "test" }).chat("model"), "xai"],
[XAI.configure({ apiKey: "test" }).responses("model"), "xai"],
] as const
for (const [model, key] of providers) expect(model.route.providerMetadataKey).toBe(key)
}),
)
it.effect("preserves native Together AI and Cerebras provider and route identities", () =>
Effect.gen(function* () {
const together = TogetherAI.configure({ apiKey: "fixture" }).model("meta-llama/Llama-3.3-70B")
@@ -68,11 +68,13 @@ for (const item of cases) {
expect(response.text.replaceAll(",", "").trim()).toBe("37887")
expect(response.reasoning.length).toBeGreaterThan(0)
expect(response.events.some(LLMEvent.is.reasoningDelta)).toBe(true)
const metadata = response.message.content.find((part) => part.type === "reasoning")?.providerMetadata
expect(metadata?.openai?.reasoningField).toBe(item.structured ? "reasoning" : "reasoning_content")
expect(Array.isArray(metadata?.openai?.reasoningDetails)).toBe(item.structured)
const metadata = response.message.content.find((part) => part.type === "reasoning")?.providerMetadata?.[
item.model.route.providerMetadataKey ?? String(item.model.provider)
]
expect(metadata?.reasoningField).toBe(item.structured ? "reasoning" : "reasoning_content")
expect(Array.isArray(metadata?.reasoningDetails)).toBe(item.structured)
if (!item.structured) return
const details = metadata?.openai?.reasoningDetails
const details = metadata?.reasoningDetails
if (!Array.isArray(details)) return
expect(
details.some(
@@ -126,7 +128,11 @@ for (const item of cases) {
).toMatch(/^Paris is sunny\.?$/)
const details = events
.filter(LLMEvent.is.reasoningEnd)
.map((event) => event.providerMetadata?.openai?.reasoningDetails)
.map(
(event) =>
event.providerMetadata?.[item.model.route.providerMetadataKey ?? String(item.model.provider)]
?.reasoningDetails,
)
.find(Array.isArray)
expect(Array.isArray(details)).toBe(item.structured)
if (!item.structured || !Array.isArray(details)) return
@@ -903,6 +903,70 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("uses the configured provider metadata namespace for reasoning and usage", () =>
Effect.gen(function* () {
const selected = LanguageModel.update(model, {
route: { ...model.route, providerMetadataKey: "vendor" },
})
const details = [{ type: "reasoning.text", text: "thinking", signature: "signed" }]
const response = yield* LLMClient.generate(LLMRequest.update(request, { model: selected })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning: "thinking", reasoning_details: details } }] },
deltaChunk({ content: "Hello" }),
deltaChunk({}, "stop"),
usageChunk({ prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 }),
),
),
),
)
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
vendor: { reasoningField: "reasoning", reasoningDetails: details },
})
expect(response.usage?.providerMetadata).toEqual({
vendor: { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 },
})
const replay = yield* compileRequest(LLM.request({ model: selected, messages: [response.message] }))
expect(replay.body.messages).toEqual([
{ role: "assistant", content: "Hello", reasoning: "thinking", reasoning_details: details },
])
}),
)
it.effect("falls back to the selected provider for the metadata namespace", () =>
Effect.gen(function* () {
const compatible = model.route.with({ provider: "deepseek" }).model({ id: "deepseek-chat" })
const selected = LanguageModel.update(compatible, {
route: { ...compatible.route, providerMetadataKey: undefined },
})
const response = yield* LLMClient.generate(LLMRequest.update(request, { model: selected })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
deltaChunk({ reasoning_content: "thinking" }),
deltaChunk({ content: "Hello" }),
deltaChunk({}, "stop"),
usageChunk({ prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 }),
),
),
),
)
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
deepseek: { reasoningField: "reasoning_content" },
})
expect(response.usage?.providerMetadata).toEqual({
deepseek: { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 },
})
const replay = yield* compileRequest(LLM.request({ model: selected, messages: [response.message] }))
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_content: "thinking" }])
}),
)
it.effect("parses and replays a configured custom reasoning field", () =>
Effect.gen(function* () {
const custom = LanguageModel.update(model, { compatibility: { reasoningField: "vendor_reasoning" } })
@@ -437,7 +437,7 @@ describe("OpenAI-compatible Chat route", () => {
outputTokens: undefined,
totalTokens: undefined,
providerMetadata: {
openai: {
deepseek: {
prompt_tokens: null,
completion_tokens: null,
total_tokens: null,
@@ -195,19 +195,19 @@ describe("Open Responses-compatible route", () => {
model,
messages: [
Message.assistant([
{ type: "text", text: "Kept.", providerMetadata: { openresponses: { itemId: "history_1" } } },
{ type: "text", text: "Kept.", providerMetadata: { "openai-compatible": { itemId: "history_1" } } },
{
type: "text",
text: "Long.",
providerMetadata: { openresponses: { itemId: `history_${"a".repeat(64)}` } },
providerMetadata: { "openai-compatible": { itemId: `history_${"a".repeat(64)}` } },
},
{
type: "text",
text: "Opaque.",
providerMetadata: { openresponses: { itemId: "provider_value/with+symbols" } },
providerMetadata: { "openai-compatible": { itemId: "provider_value/with+symbols" } },
},
{ type: "text", text: "No suffix.", providerMetadata: { openresponses: { itemId: "msg_" } } },
{ type: "text", text: "No prefix.", providerMetadata: { openresponses: { itemId: "_item" } } },
{ type: "text", text: "No suffix.", providerMetadata: { "openai-compatible": { itemId: "msg_" } } },
{ type: "text", text: "No prefix.", providerMetadata: { "openai-compatible": { itemId: "_item" } } },
]),
],
}),
@@ -267,7 +267,7 @@ describe("Open Responses-compatible route", () => {
name: item.type,
result: { type: "json", value: item },
providerExecuted: true,
providerMetadata: { openresponses: { itemId: item.id } },
providerMetadata: { example: { itemId: item.id } },
}),
),
}),
@@ -302,7 +302,7 @@ describe("Open Responses-compatible route", () => {
)
expect(response.message.content).toEqual([
{ type: "text", text: "Indexed", providerMetadata: { openresponses: { itemId: "msg_1" } } },
{ type: "text", text: "Indexed", providerMetadata: { "openai-compatible": { itemId: "msg_1" } } },
])
}),
)
@@ -368,7 +368,7 @@ describe("Open Responses-compatible route", () => {
),
)
const metadata = { openresponses: { itemId: routing.id } }
const metadata = { "openai-compatible": { itemId: routing.id } }
if (fixture.item.type === "function_call") {
expect(response.toolCalls).toEqual([
expect.objectContaining({
@@ -386,7 +386,7 @@ describe("Open Responses-compatible route", () => {
type: "reasoning",
text: "Preserved",
providerMetadata: {
openresponses: { itemId: routing.id, reasoningEncryptedContent: "encrypted-state" },
"openai-compatible": { itemId: routing.id, reasoningEncryptedContent: "encrypted-state" },
},
},
])
@@ -438,22 +438,26 @@ describe("Open Responses-compatible route", () => {
{
type: "reasoning",
text: "First.",
providerMetadata: { openresponses: { itemId: routing.id } },
providerMetadata: { "openai-compatible": { itemId: routing.id } },
},
{
type: "reasoning",
text: "Second.",
providerMetadata: { openresponses: { itemId: routing.id, reasoningEncryptedContent: "final-state" } },
providerMetadata: {
"openai-compatible": { itemId: routing.id, reasoningEncryptedContent: "final-state" },
},
},
])
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toEqual([
expect.objectContaining({
id: `${routing.id}:0`,
providerMetadata: { openresponses: { itemId: routing.id } },
providerMetadata: { "openai-compatible": { itemId: routing.id } },
}),
expect.objectContaining({
id: `${routing.id}:1`,
providerMetadata: { openresponses: { itemId: routing.id, reasoningEncryptedContent: "final-state" } },
providerMetadata: {
"openai-compatible": { itemId: routing.id, reasoningEncryptedContent: "final-state" },
},
}),
])
}),
@@ -483,7 +487,7 @@ describe("Open Responses-compatible route", () => {
id: "call_1",
name: "lookup",
input: { query: "complete" },
providerMetadata: { openresponses: { itemId: "" } },
providerMetadata: { "openai-compatible": { itemId: "" } },
}),
])
}),
@@ -510,7 +514,7 @@ describe("Open Responses-compatible route", () => {
)
expect(response.message.content).toEqual([
{ type: "text", text: "Before after", providerMetadata: { openresponses: { itemId: "msg_1" } } },
{ type: "text", text: "Before after", providerMetadata: { "openai-compatible": { itemId: "msg_1" } } },
])
expect(response.events.map((event) => event.type)).toEqual([
"step-start",
@@ -662,7 +666,7 @@ describe("Open Responses-compatible route", () => {
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
input: { query: "complete" },
providerMetadata: { openresponses: { itemId: "item_1" } },
providerMetadata: { example: { itemId: "item_1" } },
})
}),
)
@@ -694,7 +698,7 @@ describe("Open Responses-compatible route", () => {
)
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
providerMetadata: { openresponses: { itemId: "rs_raw", reasoningEncryptedContent: "raw-state" } },
providerMetadata: { "openai-compatible": { itemId: "rs_raw", reasoningEncryptedContent: "raw-state" } },
})
}),
)
@@ -743,7 +747,7 @@ describe("Open Responses-compatible route", () => {
Message.assistant({
type: "text",
text: "Unclassified.",
providerMetadata: { openresponses: { phase: null } },
providerMetadata: { "openai-compatible": { phase: null } },
}),
],
}),
@@ -802,7 +806,7 @@ describe("Open Responses-compatible route", () => {
{
type: "text",
text: "I can't help with that.",
providerMetadata: { openresponses: { itemId: "msg_refusal" } },
providerMetadata: { example: { itemId: "msg_refusal" } },
},
])
@@ -891,7 +895,7 @@ describe("Open Responses-compatible route", () => {
expect(response.toolCalls).toEqual([])
expect(response.events.find(LLMEvent.is.finish)).toMatchObject({
providerMetadata: { openresponses: { responseId: "resp_1" } },
providerMetadata: { example: { responseId: "resp_1" } },
})
}),
)
+3 -3
View File
@@ -295,7 +295,7 @@ describe("OpenRouter", () => {
{
type: "reasoning",
text: "Thinking",
providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
providerMetadata: { openrouter: { reasoningField: "reasoning", reasoningDetails: details } },
},
]),
],
@@ -328,7 +328,7 @@ describe("OpenRouter", () => {
Message.assistant({
type: "reasoning",
text: "Thinking",
providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
providerMetadata: { openrouter: { reasoningField: "reasoning", reasoningDetails: details } },
}),
],
}),
@@ -354,7 +354,7 @@ describe("OpenRouter", () => {
Message.assistant({
type: "reasoning",
text: "AB",
providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
providerMetadata: { openrouter: { reasoningField: "reasoning", reasoningDetails: details } },
}),
],
}),
+9
View File
@@ -40,4 +40,13 @@ describe("Route.with", () => {
"x-patch": "patch",
})
})
test("assigns metadata ownership to a replacement provider and preserves explicit overrides", () => {
const route = OpenAIChat.route.with({ provider: "azure" })
const overridden = route.with({ providerMetadataKey: "custom-azure" }).with({ headers: { "x-test": "value" } })
expect(route.providerMetadataKey).toBe("azure")
expect(overridden.providerMetadataKey).toBe("custom-azure")
expect(overridden.defaults).not.toHaveProperty("providerMetadataKey")
})
})
+28 -4
View File
@@ -31,7 +31,7 @@ Your app is ready to be deployed!
## E2E Testing
Playwright starts the Vite dev server automatically via `webServer`, and UI tests expect an opencode backend at `localhost:4096` by default.
Locally, Playwright starts the Vite dev server automatically via `webServer`, or reuses one already running at the configured address. The browser suite uses isolated API fixtures rather than a live opencode backend.
```bash
bunx playwright install chromium
@@ -39,11 +39,35 @@ bun run test:e2e:local
bun run test:e2e:local -- --grep "settings"
```
CI builds the app once and runs the same suite against Vite preview, serving production assets from `dist`. Managed built runs never reuse an existing server, so a running dev server cannot silently replace the production build. To run this mode locally:
```bash
bun run test:e2e:built
bun run test:e2e:built -- --grep "settings"
```
To test an already-running dev server without starting or building a server:
```bash
PLAYWRIGHT_BASE_URL=http://127.0.0.1:4444 bun run test:e2e
```
For an already-running production build, also set `PLAYWRIGHT_BUILD=1` so the fixture API uses the app's origin:
```bash
PLAYWRIGHT_BUILD=1 PLAYWRIGHT_BASE_URL=http://127.0.0.1:4444 bun run test:e2e
```
External targets must use HTTP because fixture URLs use HTTP. `PLAYWRIGHT_BASE_URL` skips server startup and building in either mode.
Compiled CLI startup and service lifecycle coverage runs separately in CI via `packages/cli/script/service-smoke.ts`.
Environment options:
- `PLAYWRIGHT_SERVER_HOST` / `PLAYWRIGHT_SERVER_PORT` (backend address, default: `localhost:4096`)
- `PLAYWRIGHT_PORT` (Vite dev server port, default: `3000`)
- `PLAYWRIGHT_BASE_URL` (override base URL, default: `http://localhost:<PLAYWRIGHT_PORT>`)
- `PLAYWRIGHT_BUILD=1` (build and preview locally; always enabled when `CI` is set)
- `PLAYWRIGHT_SERVER_HOST` / `PLAYWRIGHT_SERVER_PORT` (dev fixture API address, default: `127.0.0.1:4096`; built runs use the app's origin, matching production)
- `PLAYWRIGHT_PORT` (managed dev or preview server port, default: `3000`)
- `PLAYWRIGHT_BASE_URL` (use an externally managed app instead of starting a server; otherwise defaults to `http://127.0.0.1:<PLAYWRIGHT_PORT>`)
## Deployment
+21
View File
@@ -44,6 +44,7 @@ The suite contains:
- single-session tab close timing through stable home restoration
- cached session repaint and mutation tracing
- streaming timeline throughput, RAF-gap, long-task, geometry, and remount diagnostics
- retained renderer heap with a large model catalog across repeated session navigation
All benchmarks import the shared `benchmark` fixture. Pages created through Playwright's `page` fixture automatically capture main-frame navigation history and emit a Chrome trace when `OPENCODE_PERFORMANCE_TRACE_DIR` is set. Benchmarks that need isolated browser contexts use `withBenchmarkPage`, which owns the context and the same diagnostics lifecycle.
@@ -78,6 +79,26 @@ Benchmarks do not assert machine-dependent performance budgets. Streaming proces
Committed smoke and regression tests continue to own correctness coverage for pagination, tab paint, context resize, collapse state, and composer spacing.
Tab-switch timing starts at `mousedown`, when mouse-selected tabs actually navigate, with a `click` fallback for keyboard activation. The probe excludes hidden/transparent content and intersects answers with their virtual-row clip and viewport. The tab workload requires the destination's final answer to be visible with Markdown ready. These results are not directly comparable to older click-start, geometry-only measurements. `stableObservedMs` includes confirmation across three correct samples; `firstCorrectObservedMs` is the first sample meeting all content and geometry checks. Neither is a compositor presentation timestamp.
Each tab scenario reports one sample, including its raw observations. Use Playwright's `--repeat-each=5` for repeated measurements. Cached scenarios warm the destination at the same panel width before leaving it; a separate resized scenario validates reuse after opening the review pane changes that width.
```sh
bunx playwright test --config e2e/performance/playwright.config.ts \
timeline/session-tab-switch-benchmark.spec.ts --repeat-each=5
```
## Retained renderer memory
Run the catalog workload against the production app bundle:
```sh
bunx playwright test --config e2e/performance/playwright.config.ts \
timeline/provider-memory-benchmark.spec.ts --repeat-each=3
```
`PROVIDER_MEMORY_MODELS` defaults to 1,200 and `PROVIDER_MEMORY_SWITCHES` defaults to 10. Each sample records Chromium's `Runtime.getHeapUsage` and `Memory.getDOMCounters` after an explicit garbage collection. This measures retained state, not allocation peaks or normal GC timing. It does not include worker heaps, the Electron main/GPU processes, or the OpenCode server, and must not be reported as total desktop RAM. Use identical model counts and navigation sequences for before/after comparisons.
## Chrome traces
Set `OPENCODE_PERFORMANCE_TRACE_DIR` to emit a standard Chrome DevTools trace for every benchmark page automatically:
@@ -1,7 +1,7 @@
import config from "../../playwright.config"
const port = Number(process.env.PLAYWRIGHT_PORT ?? 3000)
process.env.PLAYWRIGHT_SERVER_PORT = String(port)
process.env.PLAYWRIGHT_SERVER_PORT = new URL(process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${port}`).port || "80"
process.env.OPENCODE_PERFORMANCE_RUN_ID ??= `${new Date().toISOString().replace(/[:.]/g, "-")}-${process.pid}`
export default {
@@ -12,9 +12,11 @@ export default {
fullyParallel: false,
workers: 1,
reporter: [["html", { outputFolder: "../playwright-report/performance", open: "never" }], ["line"]],
webServer: {
...config.webServer,
command: `bun run build && bun run serve -- --host 0.0.0.0 --port ${port} --strictPort`,
reuseExistingServer: false,
},
webServer: config.webServer
? {
...config.webServer,
command: `bun run build && bun run serve -- --host 0.0.0.0 --port ${port} --strictPort`,
reuseExistingServer: false,
}
: undefined,
}
@@ -126,13 +126,19 @@ test("keeps moving upward while drag-selecting above the timeline", async ({ pag
)
})
})
const textBox = await text.boundingBox()
const textBox = await text.evaluate((element) => {
const range = document.createRange()
range.selectNodeContents(element)
const rect = range.getClientRects()[0]
return rect ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } : null
})
const scrollBox = await scroller.boundingBox()
expect(textBox).not.toBeNull()
expect(scrollBox).not.toBeNull()
if (!textBox || !scrollBox) return
await page.mouse.move(textBox.x + textBox.width - 10, textBox.y + textBox.height / 2)
// Start on a text line, not the empty right edge or gap between wrapped lines.
await page.mouse.move(textBox.x + Math.min(20, textBox.width / 2), textBox.y + textBox.height / 2)
await page.mouse.down()
await page.mouse.move(textBox.x + 20, scrollBox.y - 120, { steps: 30 })
@@ -195,6 +201,45 @@ test("does not pull a keyboard-scrolled user during shell remeasurement", async
await reportVisualStability(testInfo, "keyboard-during-resize", trace, anchorPlan(regions))
})
test("keeps an older answer selected while scrolling within the interaction buffer", async ({ page }) => {
await setupTimeline(page, {
messages: history(80),
viewport: { width: 1400, height: 700 },
reducedMotion: true,
})
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
const answer = page.getByText("History 78.", { exact: false })
await expect(answer).toBeVisible()
await expect
.poll(() =>
answer.evaluate((element) => element.closest('[data-component="markdown"]')?.hasAttribute("data-markdown-ready")),
)
.toBe(true)
const textBox = await answer.evaluate((element) => {
const range = document.createRange()
range.selectNodeContents(element)
const rect = range.getClientRects()[0]
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
})
const scrollBox = await scroller.boundingBox()
expect(scrollBox).not.toBeNull()
if (!scrollBox) return
await page.mouse.move(textBox.x + Math.min(180, textBox.width - 2), textBox.y + textBox.height / 2)
await page.mouse.down()
await page.mouse.move(textBox.x + 2, textBox.y + textBox.height / 2, { steps: 30 })
await page.mouse.up()
await expect.poll(() => page.evaluate(() => window.getSelection()?.toString())).toContain("History 78.")
await page.mouse.move(scrollBox.x + scrollBox.width / 2, scrollBox.y + scrollBox.height / 2)
await page.mouse.wheel(0, -450)
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeGreaterThan(400)
await expect(answer).toHaveCount(1)
await expect.poll(() => page.evaluate(() => window.getSelection()?.toString())).toContain("History 78.")
await page.getByRole("heading", { name: "Timeline visual stability" }).click()
await expect.poll(() => page.evaluate(() => window.getSelection()?.isCollapsed)).toBe(true)
})
test("tracks keyboard scrolling from a focused timeline descendant", async ({ page }, testInfo) => {
const shellID = "prt_descendant_keyboard_01_shell"
const timeline = await setupTimeline(page, {
@@ -259,12 +304,16 @@ test("does not claim keyboard scrolling owned by a nested scrollable", async ({
const before = await scroller.evaluate((element) => element.scrollTop)
const nestedBefore = await nested.evaluate((element) => element.scrollTop)
await nested.press("PageUp")
await page.waitForTimeout(300)
await expect.poll(() => nested.evaluate((element) => element.scrollTop)).toBeLessThan(nestedBefore)
expect(await scroller.evaluate((element) => element.scrollTop)).toBe(before)
expect(await nested.evaluate((element) => element.scrollTop)).toBeLessThan(nestedBefore)
await nested.evaluate((element) => (element.scrollTop = 0))
await scroller.evaluate((element) => (element.scrollTop = Math.min(300, element.scrollHeight - element.clientHeight)))
await nested.evaluate((element) => element.scrollTo({ top: 0, behavior: "instant" }))
await expect.poll(() => nested.evaluate((element) => element.scrollTop)).toBe(0)
await scroller.evaluate((element) => {
element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -1 }))
element.scrollTo({ top: Math.min(300, element.scrollHeight - element.clientHeight), behavior: "instant" })
})
await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeLessThan(500)
const boundaryBefore = await scroller.evaluate((element) => element.scrollTop)
expect(boundaryBefore).toBeGreaterThan(0)
await nested.press("PageUp")
@@ -0,0 +1,98 @@
import { benchmark, expect } from "../benchmark"
import { mockOpenCodeServer } from "../../utils/mock-server"
import { expectSessionTitle } from "../../utils/waits"
import { fixture, pageMessages } from "./session-timeline-stress.fixture"
import { installStressSessionTabs, installTimelineSettings, stressSessionHref } from "./timeline-test-helpers"
import { waitForStableTimeline } from "./session-tab-switch-probe"
import type { CatalogUpdated } from "@opencode-ai/client/promise"
benchmark("measures retained renderer memory with a large model catalog", async ({ page, report }) => {
benchmark.setTimeout(120_000)
const count = Number(process.env.PROVIDER_MEMORY_MODELS ?? 1200)
const switches = Number(process.env.PROVIDER_MEMORY_SWITCHES ?? 10)
const provider = fixture.provider.all[0]
const selected = { ...provider.models["claude-opus-4-6"] }
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
sessions: fixture.sessions,
pageMessages,
provider: {
...fixture.provider,
all: [
{
...provider,
models: {
[selected.id]: selected,
...Object.fromEntries(
Array.from({ length: count - 1 }, (_, index) => {
const id = `catalog-model-${index}`
return [
id,
{
id,
name: `Catalog model ${index}`,
cost: { input: 1, output: 2 },
limit: { context: 200_000, output: 8192 },
variants: { high: { reasoningEffort: "high" } },
},
]
}),
),
},
},
],
},
})
await installTimelineSettings(page)
await installStressSessionTabs(page)
await page.goto(stressSessionHref(fixture.sourceID))
await expectSessionTitle(page, fixture.expected.sourceTitle)
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
await expect(page.locator('[data-action="composer-model"]')).toContainText("Claude Opus 4.6")
const cdp = await page.context().newCDPSession(page)
const samples = []
for (let index = 0; index <= switches; index++) {
if (index > 0) {
const target = index % 2 === 1
const id = target ? fixture.targetID : fixture.sourceID
await page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(id)}"]`).click()
await expectSessionTitle(page, target ? fixture.expected.targetTitle : fixture.expected.sourceTitle)
await waitForStableTimeline(
page,
(target ? fixture.expected.targetMessageIDs : fixture.expected.sourceMessageIDs).at(-1)!,
)
await expect(page.locator('[data-action="composer-model"]')).toContainText("Claude Opus 4.6")
}
// GC is an explicit retained-heap measurement, not an application optimization or readiness wait.
await cdp.send("HeapProfiler.collectGarbage")
samples.push({
switches: index,
heap: await cdp.send("Runtime.getHeapUsage"),
dom: await cdp.send("Memory.getDOMCounters"),
})
}
expect(samples).toHaveLength(switches + 1)
expect(samples.every((sample) => sample.heap.usedSize > 0)).toBe(true)
selected.name = "Updated catalog model"
await page.evaluate(
(event) => {
const host = window as Window & { __mockServerStream?: { push: (events: CatalogUpdated[]) => void } }
if (!host.__mockServerStream) throw new Error("Missing fixture event stream")
host.__mockServerStream.push([event])
},
{
id: "evt_catalog_refresh",
created: Date.now(),
type: "catalog.updated",
location: { directory: fixture.directory },
data: {},
} satisfies CatalogUpdated,
)
await expect(page.locator('[data-action="composer-model"]')).toContainText(selected.name)
report(
{ samples },
{ models: count, switches, gc: "explicit", scope: "renderer main isolate; not total desktop RAM" },
)
await cdp.detach()
})
@@ -11,115 +11,73 @@ import {
} from "./timeline-test-helpers"
import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
type Result = Awaited<ReturnType<typeof measureSessionSwitch>>
const scenarios = [
{ cached: false, review: false, resized: false },
{ cached: false, review: true, resized: false },
{ cached: true, review: false, resized: false },
{ cached: true, review: true, resized: false },
{ cached: true, review: true, resized: true },
]
benchmark(
"benchmarks session tab switching with and without the review pane",
async ({ browser, report }, testInfo) => {
benchmark.setTimeout(360_000)
const runs = Number(process.env.SESSION_TAB_SWITCH_RUNS ?? 5)
const results = {
closed: { cold: [] as Result[], hot: [] as Result[] },
open: { cold: [] as Result[], hot: [] as Result[] },
}
for (const reviewPane of ["closed", "open"] as const) {
for (const mode of ["cold", "hot"] as const) {
for (let run = 0; run < runs; run++) {
results[reviewPane][mode].push(
await withBenchmarkPage(
browser,
`session-tab-switch-${reviewPane}-${mode}-${run}`,
(page) => trial(page, mode, reviewPane),
testInfo,
),
)
scenarios.forEach((scenario) => {
const name = `tab switch: ${scenario.cached ? "cached" : "unmounted"}, review ${scenario.review ? "open" : "closed"}${scenario.resized ? ", resized" : ""}`
benchmark(name, async ({ browser, report }, testInfo) => {
const result = await withBenchmarkPage(
browser,
name,
async (page) => {
await mockStressTimeline(page, { vcsDiff: createReviewDiffs() })
await installTimelineSettings(page)
await installStressSessionTabs(page)
await page.goto(stressSessionHref(fixture.sourceID))
await expectSessionTitle(page, fixture.expected.sourceTitle)
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
if (scenario.review && !scenario.resized) await openReviewPane(page)
if (scenario.cached) {
await switchSession(page, fixture.targetID, fixture.expected.targetTitle)
const answer = page.locator(`[data-timeline-part-id="${fixture.expected.targetPartIDs.at(-1)}"]`)
await expect(answer.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "")
await expect
.poll(() =>
answer.evaluate((element) => element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })),
)
.toBe(true)
await waitForStableTimeline(page, fixture.expected.targetMessageIDs.at(-1)!)
await switchSession(page, fixture.sourceID, fixture.expected.sourceTitle)
}
}
}
report({ results, summary: summarizeReviewPane(results) }, { runs, reviewDiffs: createReviewDiffs().length })
},
)
if (scenario.resized) await openReviewPane(page)
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
async function trial(page: Page, mode: "cold" | "hot", reviewPane: "closed" | "open") {
const reviewDiffs = createReviewDiffs()
await mockStressTimeline(page, { vcsDiff: reviewDiffs })
await installTimelineSettings(page)
await installStressSessionTabs(page)
if (mode === "hot") {
await page.goto(stressSessionHref(fixture.targetID))
await expectSessionTitle(page, fixture.expected.targetTitle)
await waitForStableTimeline(page, fixture.expected.targetMessageIDs.at(-1)!)
await switchSession(page, fixture.sourceID, fixture.expected.sourceTitle)
} else {
await page.goto(stressSessionHref(fixture.sourceID))
await expectSessionTitle(page, fixture.expected.sourceTitle)
}
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
if (reviewPane === "open") {
await openReviewPane(page)
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
}
const destinationIDs = fixture.messages[fixture.targetID].map((message) => message.id)
const sourceIDs = fixture.messages[fixture.sourceID].map((message) => message.id)
const lastID = fixture.expected.targetMessageIDs.at(-1)!
const href = stressSessionHref(fixture.targetID)
const result = await measureSessionSwitch(page, {
destinationIDs,
sourceIDs,
lastID,
href,
switch: () => switchSession(page, fixture.targetID, fixture.expected.targetTitle),
})
return result
}
function summarize(results: Record<"cold" | "hot", Result[]>) {
const stats = (values: (number | null)[]) => {
const sorted = values.filter((value): value is number => value !== null).sort((a, b) => a - b)
return {
min: sorted[0] ?? null,
median: sorted[Math.floor(sorted.length / 2)] ?? null,
max: sorted.at(-1) ?? null,
missing: values.length - sorted.length,
}
}
return Object.fromEntries(
Object.entries(results).map(([mode, values]) => [
mode,
{
firstDestinationObservedMs: stats(values.map((value) => value.firstDestinationObservedMs)),
firstCorrectObservedMs: stats(values.map((value) => value.firstCorrectObservedMs)),
stableObservedMs: stats(values.map((value) => value.stableObservedMs)),
return measureSessionSwitch(page, {
destinationIDs: fixture.messages[fixture.targetID].map((message) => message.id),
sourceIDs: fixture.messages[fixture.sourceID].map((message) => message.id),
lastID: fixture.expected.targetMessageIDs.at(-1)!,
requiredPartID: fixture.expected.targetPartIDs.at(-1),
href: stressSessionHref(fixture.targetID),
switch: () => switchSession(page, fixture.targetID, fixture.expected.targetTitle),
})
},
]),
)
}
function summarizeReviewPane(results: Record<"closed" | "open", Record<"cold" | "hot", Result[]>>) {
return Object.fromEntries(
Object.entries(results).map(([reviewPane, values]) => [
reviewPane,
summarize(values as Record<"cold" | "hot", Result[]>),
]),
)
}
testInfo,
)
expect(result.unknownSamples).toBe(0)
expect(result.wrongDestinationSamples).toBe(0)
if (scenario.cached) expect(result.blankSamples).toBe(0)
report(result, { ...scenario, inputEvent: "mousedown", requireReadyAnswer: true })
})
})
async function switchSession(page: Page, sessionID: string, title: string) {
const href = stressSessionHref(sessionID)
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
await expect(tab).toBeVisible()
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(sessionID)}"]`)
await expect(tab).toHaveCount(1)
await tab.click()
await expectSessionTitle(page, title)
}
async function openReviewPane(page: Page) {
await page.getByRole("button", { name: "Toggle review" }).click()
const panel = page.locator("#review-panel")
await expect(panel).toBeVisible()
await expect(page.locator("#review-panel")).toBeVisible()
await page.waitForFunction(() => {
const panel = document.querySelector<HTMLElement>("#review-panel")
const text = panel?.textContent ?? ""
const text = document.querySelector("#review-panel")?.textContent ?? ""
return text.includes("generated-000.ts") && text.includes("+3")
})
}
@@ -20,9 +20,10 @@ export function classifySessionSwitch(samples: SessionSwitchSample[]) {
const firstCorrect = samples.findIndex(isCorrectDestination)
const stable = samples.findIndex((_, index) => isStableSessionSwitch(samples.slice(index, index + 3)))
return {
samples,
firstDestinationObservedMs: samples[firstDestination]?.observedAtMs ?? null,
firstCorrectObservedMs: samples[firstCorrect]?.observedAtMs ?? null,
stableObservedMs: samples[stable + 2]?.observedAtMs ?? null,
stableObservedMs: stable < 0 ? null : samples[stable + 2].observedAtMs,
wrongDestinationSamples: samples
.slice(firstDestination)
.filter((sample) => sample.destination.length > 0 && !sample.last).length,
@@ -0,0 +1,67 @@
import { benchmark, expect } from "../benchmark"
import { measureSessionSwitch } from "./session-tab-switch-probe"
import type { SessionSwitchSample } from "./session-tab-switch-metrics"
benchmark("starts at mousedown and excludes hidden or unfinished destination content", async ({ page, report }) => {
await page.setContent(`
<a href="/session/destination">Destination</a>
<div class="scroll-view__viewport" style="height:200px;overflow:auto">
<div data-timeline-row="message" data-timeline-key="row" data-message-id="source">
<div data-timeline-part-id="answer"><div data-component="markdown">Destination answer</div></div>
</div>
</div>
`)
await page.evaluate(() => {
document.querySelector("a")!.addEventListener("mousedown", () => {
const row = document.querySelector<HTMLElement>("[data-message-id]")!
row.dataset.messageId = "destination"
row.style.visibility = "hidden"
})
})
const result = await measureSessionSwitch(page, {
destinationIDs: ["destination"],
sourceIDs: ["source"],
lastID: "destination",
requiredPartID: "answer",
requireBottomAnchor: false,
href: "/session/destination",
switch: async () => {
// No click is dispatched: the probe must observe the event that activates tabs.
await page.getByRole("link", { name: "Destination" }).dispatchEvent("mousedown", { button: 0 })
await page.waitForFunction(() => {
const host = window as Window & { __sessionSwitchProbe?: { samples: SessionSwitchSample[] } }
return host.__sessionSwitchProbe?.samples.some((sample) => !sample.hasVisibleRows)
})
await page.locator("[data-message-id]").evaluate((row) => row.style.removeProperty("visibility"))
await page.waitForFunction(() => {
const host = window as Window & { __sessionSwitchProbe?: { samples: SessionSwitchSample[] } }
return host.__sessionSwitchProbe?.samples.some(
(sample) => sample.destination.length > 0 && sample.requiredPartVisible === false,
)
})
const beforeClip = await page.evaluate(() => {
const row = document.querySelector<HTMLElement>("[data-timeline-key]")!
row.style.cssText = "height:10px;position:relative;overflow:clip"
const answer = row.querySelector<HTMLElement>("[data-timeline-part-id]")!
answer.style.cssText = "position:absolute;top:30px;width:150px"
answer.querySelector('[data-component="markdown"]')!.setAttribute("data-markdown-ready", "")
return (
(window as Window & { __sessionSwitchProbe?: { samples: SessionSwitchSample[] } }).__sessionSwitchProbe
?.samples.length ?? 0
)
})
await page.waitForFunction((count) => {
const host = window as Window & { __sessionSwitchProbe?: { samples: SessionSwitchSample[] } }
return host.__sessionSwitchProbe?.samples.slice(count).some((sample) => sample.requiredPartVisible === false)
}, beforeClip)
await page.locator("[data-timeline-key]").evaluate((row) => {
row.style.height = "100px"
})
},
})
expect(result.blankSamples).toBeGreaterThan(0)
expect(result.firstCorrectObservedMs).not.toBeNull()
expect(result.stableObservedMs).not.toBeNull()
expect(result.firstCorrectObservedMs).toBeGreaterThan(result.firstDestinationObservedMs!)
report(result)
})
@@ -25,7 +25,7 @@ async function installSessionSwitchProbe(
let running = true
const reviewLevels: Record<string, string> = {
panel: "#review-panel",
tabs: '#review-panel [data-component="tabs"]',
tabs: '#review-panel [data-component="tabs"]',
body: '#review-panel [data-slot="session-review-v2-body"]',
review: '#review-panel [data-component="session-review-v2"]',
preview: '#review-panel [data-slot="session-review-v2-preview"]',
@@ -37,7 +37,6 @@ async function installSessionSwitchProbe(
if (!running || started === undefined) return
setTimeout(() => {
if (!running || started === undefined) return
const observedAtMs = performance.now() - started
const reviewPanel = document.querySelector<HTMLElement>("#review-panel")
const reviewFile = reviewPanel?.querySelector('[data-component="file"][data-mode="diff"]')
const initialReviewFile = initialReviewNodes.file
@@ -63,26 +62,30 @@ async function installSessionSwitchProbe(
)
if (root) {
const view = root.getBoundingClientRect()
const visible = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
.filter((element) => {
const rect = element.getBoundingClientRect()
return rect.bottom > view.top && rect.top < view.bottom
})
.map((element) => element.dataset.messageId!)
const hasVisibleRows = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")].some((element) => {
const inViewport = (element: HTMLElement) => {
if (!element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) return false
const rect = element.getBoundingClientRect()
return rect.bottom > view.top && rect.top < view.bottom
})
const clip = element.closest<HTMLElement>("[data-timeline-key]")?.getBoundingClientRect() ?? view
return (
Math.min(rect.bottom, clip.bottom, view.bottom) > Math.max(rect.top, clip.top, view.top) &&
Math.min(rect.right, clip.right, view.right) > Math.max(rect.left, clip.left, view.left)
)
}
const visible = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
.filter(inViewport)
.map((element) => element.dataset.messageId!)
const hasVisibleRows = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")].some(inViewport)
const requiredPartVisible = requiredPartID
? [...root.querySelectorAll<HTMLElement>("[data-timeline-part-id]")].some((element) => {
if (element.dataset.timelinePartId !== requiredPartID) return false
const rect = element.getBoundingClientRect()
return rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom
if (!element.textContent?.trim()) return false
if (element.querySelector('[data-component="markdown"]:not([data-markdown-ready])')) return false
return inViewport(element)
})
: undefined
const spacer = root.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
samples.push({
observedAtMs,
observedAtMs: performance.now() - started,
destination: visible.filter((id) => destination.has(id)),
source: visible.filter((id) => source.has(id)),
hasVisibleRows,
@@ -94,7 +97,7 @@ async function installSessionSwitchProbe(
})
} else {
samples.push({
observedAtMs,
observedAtMs: performance.now() - started,
destination: [],
source: [],
hasVisibleRows: false,
@@ -107,23 +110,25 @@ async function installSessionSwitchProbe(
requestAnimationFrame(sample)
}, 0)
}
document.addEventListener(
"click",
(event) => {
const link = event.target instanceof Element ? event.target.closest("a") : undefined
if (link?.getAttribute("href") !== href) return
started = performance.now()
for (const [name, selector] of Object.entries(reviewLevels)) {
initialReviewNodes[name] = document.querySelector(selector)
}
requestAnimationFrame(sample)
},
{ capture: true, once: true },
)
const start = (event: MouseEvent) => {
if (started !== undefined || event.button !== 0) return
const link = event.target instanceof Element ? event.target.closest("a") : undefined
if (link?.getAttribute("href") !== href) return
started = performance.now()
for (const [name, selector] of Object.entries(reviewLevels)) {
initialReviewNodes[name] = document.querySelector(selector)
}
requestAnimationFrame(sample)
}
// Tabs activate on mousedown; click alone misses the synchronous navigation work.
document.addEventListener("mousedown", start, true)
document.addEventListener("click", start, true)
;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe = {
samples,
stop: () => {
running = false
document.removeEventListener("mousedown", start, true)
document.removeEventListener("click", start, true)
},
}
}, input)
@@ -53,6 +53,15 @@ test("reports missing correctness without throwing", () => {
expect(result.stableObservedMs).toBeNull()
})
test("does not report stability for only two correct samples", () => {
const result = classifySessionSwitch([
{ observedAtMs: 16, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
{ observedAtMs: 32, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
])
expect(result.firstCorrectObservedMs).toBe(16)
expect(result.stableObservedMs).toBeNull()
})
test("requires an explicitly tracked part to be visible", () => {
const result = classifySessionSwitch([
{
@@ -3,7 +3,7 @@ import { base64Encode } from "@opencode-ai/util/encode"
import { currentSession } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport"
const serverA = "http://127.0.0.1:4096"
const serverA = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const serverB = "http://127.0.0.1:4097"
const sessionA = session("ses_server_a", "C:/server-a", "Server A session")
const sessionB = session("ses_server_b", "/home/server-b", "Server B session")
@@ -12,17 +12,17 @@ test("closing the active server's last tab opens the remaining server tab", asyn
const requests: string[] = []
await mockServers(page, requests)
await page.addInitScript(
({ serverB, sessionA, sessionB }) => {
({ serverA, serverB, sessionA, sessionB }) => {
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
{ type: "session", server: "http://127.0.0.1:4096", sessionId: sessionA },
{ type: "session", server: serverA, sessionId: sessionA },
{ type: "session", server: serverB, sessionId: sessionB },
]),
)
},
{ serverB, sessionA: sessionA.id, sessionB: sessionB.id },
{ serverA, serverB, sessionA: sessionA.id, sessionB: sessionB.id },
)
const hrefA = `/server/${base64Encode(serverA)}/session/${sessionA.id}`
@@ -55,7 +55,7 @@ function session(id: string, directory: string, title: string) {
async function mockServers(page: Page, requests: string[]) {
await installSseTransport(page, { server: serverA })
await installSseTransport(page, { server: serverB })
await page.route("**/*", async (route) => {
await page.route("**/api/**", async (route) => {
const url = new URL(route.request().url())
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
requests.push(url.toString())
@@ -0,0 +1,103 @@
import { expect, test } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
const directory = "C:/Projects/extensions-demo"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const session = {
id: "ses_project_extensions",
title: "Existing session",
directory,
projectID: "proj_extensions_demo",
time: { created: 1700000000000, updated: 1700000000000 },
}
test.use({ viewport: { width: 1440, height: 1000 }, colorScheme: "dark" })
test("project Extensions stays inside settings while plugins load", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: session.projectID,
canonical: directory,
name: "Extensions demo",
vcs: "git",
time: session.time,
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [session],
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ server, sessionID, directory }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ server, sessionID: session.id, directory },
)
const href = `/server/${base64Encode(server)}/session/${session.id}`
await page.goto(href)
await expect(page.getByRole("heading", { name: session.title, exact: true })).toBeVisible()
await page.keyboard.press("Control+,")
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
await settings.getByText("Extensions demo", { exact: true }).click()
const dialog = page.getByRole("dialog")
await expect(dialog.getByRole("textbox", { name: "Name", exact: true })).toBeFocused()
const globalPlugins = Promise.withResolvers<void>()
const projectPlugins = Promise.withResolvers<void>()
await page.route(
(url) => url.pathname === "/api/plugin",
async (route) => {
const project = new URL(route.request().url()).searchParams.get("location[directory]")
await (project ? projectPlugins : globalPlugins).promise
await route.fulfill({
json: {
location: project ? { directory: project } : {},
data: (project ? ["shared-plugin", "project-plugin"] : ["shared-plugin"]).map((id) => ({
id,
source: { type: "package", package: id },
status: "active",
tui: false,
})),
},
})
},
)
const requested = page.waitForRequest((request) => {
const url = new URL(request.url())
return url.pathname === "/api/plugin" && url.searchParams.get("location[directory]") === directory
})
await dialog.getByRole("tab", { name: "Extensions", exact: true }).click()
await requested
await expect(page).toHaveURL(href)
await expect(dialog.getByRole("heading", { name: "Extensions", exact: true })).toBeVisible()
await expect(settings).toBeVisible()
await expect(page.getByRole("heading", { name: session.title, exact: true, includeHidden: true })).toBeHidden()
await dialog.getByRole("tab", { name: "Plugins", exact: true }).click()
await expect(dialog.getByRole("tab", { name: "Plugins", exact: true })).toHaveAttribute("aria-selected", "true")
globalPlugins.resolve()
await dialog.getByRole("tab", { name: "Scripts", exact: true }).click()
await expect(dialog.getByRole("heading", { name: "Scripts", exact: true })).toBeVisible()
await dialog.getByRole("tab", { name: "Extensions", exact: true }).click()
projectPlugins.resolve()
await dialog.getByRole("tab", { name: "Plugins", exact: true }).click()
await expect(dialog.getByText("project-plugin", { exact: true })).toBeVisible()
await dialog.getByRole("button", { name: "Shared with all projects 1", exact: true }).click()
await expect(dialog.getByText("shared-plugin", { exact: true })).toBeVisible()
await expect(page).toHaveURL(href)
await page.keyboard.press("Escape")
await expect(dialog).toBeHidden()
await expect(settings.getByRole("tab", { name: "Projects", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(page.getByRole("heading", { name: session.title, exact: true, includeHidden: true })).toBeHidden()
})
@@ -3,7 +3,7 @@ import { expect, test, type Page, type Route } from "@playwright/test"
import { installSseTransport } from "../utils/sse-transport"
import { currentSession } from "../utils/mock-server"
const serverA = `http://127.0.0.1:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const serverA = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const serverB = "http://127.0.0.1:4097"
const directoryA = "C:/server-a"
const directoryB = "/home/server-b"
@@ -308,7 +308,7 @@ async function mockServers(
permissionResponses: PermissionResponse[] = [],
options: MockServerOptions = {},
) {
await page.route("**/*", async (route) => {
await page.route("**/api/**", async (route) => {
const url = new URL(route.request().url())
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
const remote = url.origin === serverB
@@ -2,7 +2,7 @@ import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode"
import { currentSession } from "../utils/mock-server"
const serverA = "http://127.0.0.1:4096"
const serverA = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const serverB = "http://127.0.0.1:4097"
const sessionA = session("ses_server_a", "C:/server-a", "Server A session")
const sessionB = session("ses_server_b", "/home/server-b", "Server B session")
@@ -51,7 +51,7 @@ function session(id: string, directory: string, title: string) {
}
async function mockServers(page: Page) {
await page.route("**/*", async (route) => {
await page.route("**/api/**", async (route) => {
const url = new URL(route.request().url())
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
const current = url.origin === serverA ? sessionA : sessionB
@@ -1,5 +1,5 @@
import { expect, test, type Page } from "@playwright/test"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client/promise"
import { base64Encode } from "@opencode-ai/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
@@ -18,7 +18,7 @@ type InboxRow = {
delivery: "steer" | "queue"
}
function createQueueMock(seed: string[]) {
function createQueueMock(seed: string[], messages: SessionMessageInfo[] = []) {
const rows: InboxRow[] = seed.map((text, index) => ({
id: `inb_seed_${index + 1}`,
sessionID,
@@ -32,13 +32,16 @@ function createQueueMock(seed: string[]) {
const changes: { inboxID: string; action: "cancel" | "steer" }[] = []
const log: string[] = []
let sequence = 0
const emit = (type: OpenCodeEvent["type"], data: OpenCodeEvent["data"]) => {
const emit = <Type extends OpenCodeEvent["type"]>(
type: Type,
data: Extract<OpenCodeEvent, { type: Type }>["data"],
) => {
sequence += 1
events.push({
id: `evt_queue_${sequence}`,
type,
created: Date.now(),
durable: { aggregateID: sessionID, seq: sequence, version: 1 },
durable: { aggregateID: sessionID, seq: sequence, version: type === "session.tool.success" ? 2 : 1 },
data,
} as OpenCodeEvent)
}
@@ -47,6 +50,8 @@ function createQueueMock(seed: string[]) {
prompts,
changes,
log,
messages,
emit,
events: () => events.splice(0),
onPrompt: (input: { sessionID: string; body: Record<string, unknown> }) => {
prompts.push(input.body)
@@ -126,10 +131,11 @@ async function openSession(page: Page, mock: ReturnType<typeof createQueueMock>,
directory,
title: "Session queue regression",
version: "dev",
model: { id: "queue-model", providerID: "opencode" },
time: { created: 1700000000000, updated: 1700000000000 },
},
],
pageMessages: () => ({ items: [] }),
pageMessages: () => ({ items: mock.messages }),
sessionStatus: () => ({ [sessionID]: { type: "running" } }),
inbox: () => mock.rows.map((row) => ({ ...row, payload: { ...row.payload } })),
onPrompt: mock.onPrompt,
@@ -227,3 +233,130 @@ test("editing restores the existing draft and replaces only the original queue p
expect(mock.changes.map((change) => change.action)).toEqual(["cancel", "cancel", "cancel"])
expect(mock.log[0]).toBe("prompt:queue")
})
for (const delivery of ["steer", "queue"] as const) {
test(`keeps finished tools above a pending ${delivery === "queue" ? "queue-to-steer" : "steer"} follow-up`, async ({
page,
}, testInfo) => {
const model = { id: "queue-model", providerID: "opencode" }
const userID = "msg_queue_initial_user"
const assistantID = "msg_queue_continued_assistant"
const followUp = "U2: Also check the retry path."
const mock = createQueueMock(
[],
[
{ id: userID, type: "user", text: "U1: Inspect the queue ordering.", time: { created: 1700000000000 } },
{
id: "msg_queue_initial_assistant",
type: "assistant",
agent: "build",
model,
content: [{ type: "text", text: "A1: I will inspect the current implementation." }],
finish: "tool-calls",
time: { created: 1700000000001, completed: 1700000000002 },
},
],
)
const view = await openSession(page, mock, delivery)
const transcript = page.locator("[data-timeline-virtual-content]")
const thinking = transcript.locator('[data-timeline-row="Thinking"]')
await expect(transcript.getByText("A1: I will inspect the current implementation.", { exact: true })).toBeVisible()
await expect(thinking).toBeVisible()
await expect(view.input).toBeEditable()
await view.input.fill(followUp)
await view.input.press("Enter")
await expect.poll(() => mock.rows.map((row) => row.delivery)).toEqual([delivery])
await expect(view.input).toHaveText("")
const inboxID = mock.rows[0].id
const pending = transcript.locator(`[data-timeline-row="UserMessage"][data-message-id="${inboxID}"]`)
if (delivery === "queue") {
const queued = view.rows.filter({ hasText: followUp })
await expect(queued).toBeVisible()
await expect(pending).toHaveCount(0)
await queued.hover()
await queued.getByRole("button", { name: "Steer", exact: true }).click()
await expect.poll(() => mock.changes).toEqual([{ inboxID, action: "steer" }])
}
await expect(view.rows).toHaveCount(0)
await expect(pending).toContainText(followUp)
// The next assistant step still belongs to U1: U2 has been admitted, not delivered.
mock.emit("session.step.started", { sessionID, assistantMessageID: assistantID, agent: "build", model })
for (const tool of [
{ id: "tool_queue_read", name: "read", input: { path: "src/queue.ts" } },
{ id: "tool_queue_grep", name: "grep", input: { pattern: "retry", path: "src" } },
]) {
const ref = { sessionID, assistantMessageID: assistantID, id: tool.id }
mock.emit("session.tool.input.started", { ...ref, name: tool.name })
mock.emit("session.tool.input.ended", { ...ref, text: JSON.stringify(tool.input) })
mock.emit("session.tool.called", { ...ref, input: tool.input, executed: true })
mock.emit("session.tool.success", {
...ref,
content: [{ type: "text", text: "Inspection complete." }],
executed: true,
})
}
mock.emit("session.step.ended", {
sessionID,
assistantMessageID: assistantID,
finish: "tool-calls",
cost: 0,
tokens: { input: 100, output: 20, reasoning: 0, cache: { read: 0, write: 0 } },
})
const tools = page.locator('[data-timeline-part-ids="tool_queue_read,tool_queue_grep"]')
await expect(tools).toBeVisible()
await expect(tools).toContainText(/Used\s*Read, Grep/)
await expect(tools.locator('[data-component="tag"]')).toHaveText("2")
await expect(thinking).toBeVisible()
await expect(pending).toBeVisible()
expect(mock.rows.map((row) => ({ id: row.id, delivery: row.delivery }))).toEqual([
{ id: inboxID, delivery: "steer" },
])
await transcript.screenshot({ path: testInfo.outputPath("pending-steer.png") })
// Soft assertions let delivery run too, even when the pending ordering regresses.
await expect
.soft(tools.or(thinking).or(pending))
.toHaveText([/Used\s*Read, Grep/, /Thinking/, /U2: Also check the retry path\./])
await expect
.soft(transcript.locator('[data-timeline-row="AssistantPart"]').filter({ has: tools }))
.toHaveAttribute("data-message-id", userID)
await expect
.configure({ soft: true })
.poll(async () => {
const boxes = await Promise.all([tools.boundingBox(), thinking.boundingBox(), pending.boundingBox()])
return (
boxes.every((box) => box !== null) &&
boxes[0]!.y + boxes[0]!.height <= boxes[1]!.y &&
boxes[1]!.y + boxes[1]!.height <= boxes[2]!.y
)
})
.toBe(true)
mock.rows.splice(0, 1)
mock.emit("session.inbox.delivered", { sessionID, inboxID })
await expect(thinking).toHaveAttribute("data-message-id", inboxID)
await expect(pending).toHaveCount(1)
await expect(transcript.locator('[data-timeline-row="UserMessage"]')).toHaveCount(2)
await expect(transcript.locator('[data-timeline-row="AssistantPart"]').filter({ has: tools })).toHaveAttribute(
"data-message-id",
userID,
)
const later = { sessionID, assistantMessageID: "msg_queue_follow_up_assistant" }
mock.emit("session.step.started", { ...later, agent: "build", model })
mock.emit("session.text.started", { ...later, ordinal: 0 })
mock.emit("session.text.ended", { ...later, ordinal: 0, text: "A3: Now checking the retry path for U2." })
const response = transcript
.locator('[data-timeline-row="AssistantPart"]')
.filter({ hasText: "A3: Now checking the retry path for U2." })
await expect(response).toHaveAttribute("data-message-id", inboxID)
await expect(tools.or(pending).or(response).or(thinking)).toHaveText([
/Used\s*Read, Grep/,
/U2: Also check the retry path\./,
/A3: Now checking the retry path for U2\./,
/Thinking/,
])
})
}
@@ -0,0 +1,121 @@
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
const directory = "C:/Projects/settings-demo"
const sandboxes = Array.from({ length: 12 }, (_, index) => `${directory}/workspace-${index + 1}`)
test.use({ viewport: { width: 1440, height: 1000 }, colorScheme: "dark" })
test.beforeEach(async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_settings_demo",
canonical: directory,
name: "Settings demo",
vcs: "git",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes,
},
provider: { all: [], connected: [], default: {} },
sessions: sandboxes.map((directory, index) => ({
id: `ses_settings_${index + 1}`,
title: `Workspace ${index + 1} session`,
directory,
projectID: "proj_settings_demo",
time: { created: 1700000000000, updated: 1700000000000 },
})),
pageMessages: () => ({ items: [] }),
})
await page.goto("/")
await page.getByRole("button", { name: "Settings", exact: true }).click()
await expect(page.getByTestId("settings-screen").getByRole("tab", { name: "Preferences" })).toBeVisible()
})
test("workspaces opens without waiting for inventory or sessions", async ({ page }) => {
const inventory = Promise.withResolvers<void>()
const sessions = Promise.withResolvers<void>()
await page.route("**/api/worktree/*", async (route) => {
await inventory.promise
await route.fallback()
})
await page.route("**/api/session?*", async (route) => {
if (new URL(route.request().url()).searchParams.has("directory")) await sessions.promise
await route.fallback()
})
const settings = page.getByTestId("settings-screen")
const requested = page.waitForRequest((request) => new URL(request.url()).pathname.startsWith("/api/worktree/"))
await settings.getByRole("tab", { name: "Workspaces", exact: true }).click()
await requested
await expect(settings.getByRole("heading", { name: "Workspaces", exact: true })).toBeVisible()
await expect(settings.getByRole("button", { name: "Back to app" })).toBeVisible()
await expect(settings.getByText("No workspaces", { exact: true })).toHaveCount(0)
inventory.resolve()
await expect(settings.getByText(sandboxes[0], { exact: true })).toBeVisible()
await expect(settings.getByText("12 workspaces", { exact: true })).toBeVisible()
sessions.resolve()
await expect(settings.getByText("Workspace 1 session", { exact: true })).toBeVisible()
const refresh = Promise.withResolvers<void>()
await page.route("**/api/worktree/*", async (route) => {
await refresh.promise
await route.fallback()
})
await settings.getByRole("tab", { name: "Preferences", exact: true }).click()
await settings.getByRole("tab", { name: "Workspaces", exact: true }).click()
await expect(settings.getByText("Workspace 1 session", { exact: true })).toBeVisible()
refresh.resolve()
})
test("extensions opens without waiting for MCPs or plugins", async ({ page }) => {
const mcps = Promise.withResolvers<void>()
const plugins = Promise.withResolvers<void>()
await page.route("**/api/mcp", async (route) => {
await mcps.promise
await route.fulfill({
json: { location: { directory }, data: [{ name: "demo-mcp", status: { status: "connected" } }] },
})
})
await page.route("**/api/plugin", async (route) => {
await plugins.promise
await route.fulfill({
json: {
location: { directory },
data: [
{ id: "demo-plugin", source: { type: "package", package: "demo-plugin" }, status: "active", tui: false },
],
},
})
})
const settings = page.getByTestId("settings-screen")
const requested = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/mcp")
await settings.getByRole("tab", { name: "Extensions", exact: true }).click()
await requested
await expect(settings.getByRole("heading", { name: "Extensions", exact: true })).toBeVisible()
await expect(settings.getByRole("button", { name: "Back to app" })).toBeVisible()
await settings.getByRole("tab", { name: "Plugins", exact: true }).click()
await expect(settings.getByRole("tab", { name: "Plugins", exact: true })).toHaveAttribute("aria-selected", "true")
plugins.resolve()
await expect(settings.getByText("demo-plugin", { exact: true })).toBeVisible()
mcps.resolve()
await settings.getByRole("tab", { name: "MCPs", exact: true }).click()
await expect(settings.getByRole("switch", { name: "demo-mcp" })).toBeChecked()
})
test("workspace inventory uses the settings panel scroll area", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Workspaces", exact: true }).click()
await expect(settings.getByText("Workspace 1 session", { exact: true })).toBeVisible()
const list = settings.locator('[data-component="settings-list"]')
await expect(list).toHaveCSS("max-height", "none")
await expect(list).toHaveCSS("overflow-y", "visible")
await settings.getByText("Workspace 12 session", { exact: true }).scrollIntoViewIfNeeded()
await expect(settings.getByText("Workspace 12 session", { exact: true })).toBeInViewport()
await expect(settings.getByRole("button", { name: "Back to app" })).toBeInViewport()
await page.setViewportSize({ width: 390, height: 844 })
await expect(list).toHaveCSS("max-height", "none")
await expect(list).toHaveCSS("overflow-y", "visible")
await settings.getByText("Workspace 12 session", { exact: true }).scrollIntoViewIfNeeded()
await expect(settings.getByText("Workspace 12 session", { exact: true })).toBeInViewport()
})
@@ -2,7 +2,7 @@ import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode"
import { currentSession } from "../utils/mock-server"
const server = "http://127.0.0.1:4096"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const sessionA = session("ses_tab_a", "Tab A session")
const sessionB = session("ses_tab_b", "Tab B session")
const sessionC = session("ses_tab_c", "Tab C session")
@@ -238,7 +238,7 @@ function session(id: string, title: string) {
async function mockServer(page: Page) {
const sessions = [sessionA, sessionB, sessionC]
await page.route("**/*", async (route) => {
await page.route("**/api/**", async (route) => {
const url = new URL(route.request().url())
if (url.origin !== server) return route.fallback()
if (url.pathname === `/api/session/${unresolvedSessionID}`) return new Promise(() => {})
+3 -1
View File
@@ -144,12 +144,14 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
)
page.on("close", () => void transport.dispose())
await page.route("**/*", async (route) => {
await page.route("**/api/**", async (route) => {
const url = new URL(route.request().url())
const appPort = new URL(
process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${process.env.PLAYWRIGHT_PORT ?? "3000"}`,
).port
if (url.origin !== server && url.port !== appPort) return route.fallback()
// Production serves the UI and API from one origin; leave app assets to Vite.
if (!url.pathname.startsWith("/api/")) return route.fallback()
if (route.request().method() === "OPTIONS") {
return route.fulfill({ status: 204, headers: corsHeaders })
}
+1
View File
@@ -25,6 +25,7 @@
"test:browser": "bun test --conditions=browser --preload ./happydom.ts ./test-browser",
"test:unit:watch": "bun test --conditions=solid --watch --preload ./happydom.ts ./src",
"test:e2e": "playwright test",
"test:e2e:built": "PLAYWRIGHT_BUILD=1 playwright test",
"test:e2e:local": "playwright test",
"test:components": "playwright test --config playwright.components.config.ts",
"test:components:ui": "playwright test --config playwright.components.config.ts --ui",
+23 -12
View File
@@ -2,10 +2,19 @@ import { defineConfig, devices } from "@playwright/test"
const port = Number(process.env.PLAYWRIGHT_PORT ?? 3000)
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${port}`
const url = new URL(baseURL)
if (url.protocol !== "http:") throw new Error("E2E fixtures require an http:// app URL")
const built = !!process.env.CI || process.env.PLAYWRIGHT_BUILD === "1"
// Production connects to its own origin, so fixture URLs must match the preview server.
if (built) {
process.env.PLAYWRIGHT_SERVER_HOST = url.hostname
process.env.PLAYWRIGHT_SERVER_PORT = url.port || "80"
}
const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"
const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
const reuse = !process.env.CI
const command = built
? `bun run build && bun run serve -- --host 127.0.0.1 --port ${port} --strictPort`
: `bun run dev -- --host 127.0.0.1 --port ${port} --strictPort`
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
export default defineConfig({
testDir: "./e2e",
@@ -23,16 +32,18 @@ export default defineConfig({
retries: process.env.CI ? 2 : 0,
workers,
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
webServer: {
command,
url: baseURL,
reuseExistingServer: reuse,
timeout: 120_000,
env: {
VITE_OPENCODE_SERVER_HOST: serverHost,
VITE_OPENCODE_SERVER_PORT: serverPort,
},
},
webServer: process.env.PLAYWRIGHT_BASE_URL
? undefined
: {
command,
url: baseURL,
reuseExistingServer: !built,
timeout: 120_000,
env: {
VITE_OPENCODE_SERVER_HOST: serverHost,
VITE_OPENCODE_SERVER_PORT: serverPort,
},
},
use: {
baseURL,
trace: "on-first-retry",
+1 -1
View File
@@ -17,7 +17,7 @@ export function Home() {
return (
<div
class={`
m-2 min-h-0 flex-1 self-stretch overflow-hidden rounded-[10px]
mx-2 mb-2 mt-[var(--shell-top-inset,8px)] min-h-0 flex-1 self-stretch overflow-hidden rounded-[10px]
bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]
`}
>
+1 -1
View File
@@ -73,7 +73,7 @@ export default function NewSessionPage(props: { draftId: string }) {
<div class="relative size-full overflow-hidden flex flex-col">
{suspendUntilPromptReady()}
<NewSessionStatus visible={settings.visibility.status()} />
<div class="flex-1 min-h-0 flex flex-col gap-2 p-2">
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-2 pt-[var(--shell-top-inset,8px)]">
<NewSessionView composer={model} project={project} workspace={workspace} />
</div>
</div>
@@ -1,10 +1,16 @@
import type { AgentListOutput, ModelListOutput, ProviderListOutput } from "@opencode-ai/client/promise"
import type { Agent, Project, Provider, ProviderListResponse } from "@/runtime/server/types"
import type { Project as CurrentProject } from "@opencode-ai/client/promise"
import { unwrap } from "solid-js/store"
export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/workspaces/path-key"
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
const providerCatalogs = new WeakMap<
ProviderListOutput["data"],
WeakMap<ModelListOutput["data"], ProviderListResponse>
>()
export function normalizeAgentList(input: AgentListOutput["data"] | Agent[]): Agent[] {
if (input.every((agent) => !("request" in agent))) return input as Agent[]
return (input as AgentListOutput["data"]).map((agent) => ({
@@ -30,12 +36,16 @@ export function normalizeAgentList(input: AgentListOutput["data"] | Agent[]): Ag
}
export function normalizeProviderList(
providers: ProviderListOutput["data"] | ProviderListResponse,
models?: ModelListOutput["data"],
input: ProviderListOutput["data"] | ProviderListResponse,
catalog?: ModelListOutput["data"],
): ProviderListResponse {
if (!Array.isArray(providers)) {
return providers
}
if (!Array.isArray(input)) return input
// Client sync replaces whole catalog lists. Track those reads at the caller,
// not every model field, and share conversions without retaining old lists.
const providers = unwrap(input)
const models = unwrap(catalog)
const cached = models && providerCatalogs.get(providers)?.get(models)
if (cached) return cached
const all = new Map<string, Provider>()
for (const provider of providers) {
@@ -101,7 +111,7 @@ export function normalizeProviderList(
}
}
return {
const result = {
all,
connected: providers.map((provider) => provider.id),
default: Object.fromEntries(
@@ -111,6 +121,12 @@ export function normalizeProviderList(
}),
),
}
if (models) {
const cache = providerCatalogs.get(providers) ?? new WeakMap<ModelListOutput["data"], ProviderListResponse>()
cache.set(models, result)
providerCatalogs.set(providers, cache)
}
return result
}
export function normalizeProjectInfo(project: Project | CurrentProject): Project {
+2 -4
View File
@@ -44,9 +44,7 @@ function TargetSessionSettingsCommand() {
return null
}
function SessionRouteErrorBoundary(
props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key }>,
) {
function SessionRouteErrorBoundary(props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key }>) {
return (
<ErrorBoundary
fallback={(error) => (
@@ -106,7 +104,7 @@ function PendingSessionState(props: { sessionID: string }) {
function SessionStatePanel(props: ParentProps) {
return (
<div class="flex min-h-0 flex-1 p-2">
<div class="flex min-h-0 flex-1 px-2 pb-2 pt-[var(--shell-top-inset,8px)]">
<SessionPanelFrame raised>{props.children}</SessionPanelFrame>
</div>
)
+1 -2
View File
@@ -8,7 +8,7 @@ import type { SessionModel } from "./model"
import { sessionPanelLayout } from "./session-panel-layout"
import { clampSessionPanelWidth, sessionPanelWidthMax } from "./session-panel-width"
export function createSessionScreenLayout(session: SessionModel, serverScope: string) {
export function createSessionScreenLayout(session: SessionModel) {
const layout = useLayout()
const settings = useSettings()
const size = createSizing()
@@ -92,7 +92,6 @@ export function createSessionScreenLayout(session: SessionModel, serverScope: st
centered: createMemo(() => session.isDesktop()),
files: { open: fileTreeOpen },
panel: {
key: createMemo(() => (session.identity.params.id ? `${serverScope}\0${session.identity.params.id}` : undefined)),
max: panelMax,
ref: (element: HTMLDivElement) => {
row = element
+6 -10
View File
@@ -4,7 +4,6 @@ import createPresence from "solid-presence"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { SessionHeader } from "@/session/header/session-header"
import { useLayout } from "@/shell/state/layout"
import { useServerSDK } from "@/runtime/server/client"
import { useSettings } from "@/settings/model"
import { MessageTimeline } from "@/session/timeline/message-timeline"
import type { SessionModel } from "@/session/model"
@@ -23,10 +22,9 @@ import { SessionIdentityHeader } from "./session-identity-header"
export function SessionScreen(props: { session: SessionModel }) {
const session = props.session
const layout = useLayout()
const serverSDK = useServerSDK()
const settings = useSettings()
const isDesktop = session.isDesktop
const screen = createSessionScreenLayout(session, serverSDK.scope)
const screen = createSessionScreenLayout(session)
const timeline = createSessionTimelineInteraction(session)
const messagesReady = timeline.ready
const [store, setStore] = createStore({
@@ -164,7 +162,7 @@ export function SessionScreen(props: { session: SessionModel }) {
return (
<>
<SessionHeader />
<div class="flex-1 min-h-0 flex flex-col gap-2 p-2">
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-2 pt-[var(--shell-top-inset,8px)]">
<div ref={screen.panel.ref} class="relative flex-1 min-h-0 flex flex-col md:flex-row gap-2">
<div
classList={{
@@ -177,12 +175,10 @@ export function SessionScreen(props: { session: SessionModel }) {
width: screen.panel.width(),
}}
>
<Show when={screen.panel.key()} keyed>
{(_) => (
<SessionPanelFrame raised={!!session.identity.params.id}>
<ErrorBoundary fallback={sessionErrorFallback}>{sessionPanelContent()}</ErrorBoundary>
</SessionPanelFrame>
)}
<Show when={!!session.identity.params.id}>
<SessionPanelFrame raised>
<ErrorBoundary fallback={sessionErrorFallback}>{sessionPanelContent()}</ErrorBoundary>
</SessionPanelFrame>
</Show>
<Show when={screen.panel.resizable()}>
+4 -1
View File
@@ -2,7 +2,10 @@ import type { ParentProps } from "solid-js"
export function SessionRouteFrame(props: ParentProps<{ padded?: boolean }>) {
return (
<div class="relative flex size-full flex-col overflow-hidden" classList={{ "p-2": props.padded }}>
<div
class="relative flex size-full flex-col overflow-hidden"
classList={{ "px-2 pb-2 pt-[var(--shell-top-inset,8px)]": props.padded }}
>
{props.children}
</div>
)
@@ -1,6 +1,8 @@
import { describe, expect, test } from "bun:test"
import type { SessionInboxInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
import { createRoot } from "solid-js"
import { applyTimelineMessageHandoff, visibleTimelineMessages } from "./controller-projection"
import { createTimelineProjection } from "./projection"
const messages = [
{ id: "msg_1", type: "user", text: "first", time: { created: 1 } },
@@ -17,6 +19,105 @@ const messages = [
] satisfies SessionMessageInfo[]
describe("visibleTimelineMessages", () => {
const steer = {
id: "msg_3",
sessionID: "ses_1",
timeCreated: 3,
type: "user",
delivery: "steer",
payload: { text: "queued" },
} satisfies SessionInboxInfo
const work = {
id: "msg_5",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [
{
type: "tool",
id: "tool_read",
name: "read",
state: {
status: "completed",
input: { filePath: "src/example.ts" },
content: [{ type: "text", text: "export const example = true" }],
metadata: {},
},
time: { created: 5, completed: 6 },
},
],
time: { created: 5, completed: 6 },
} satisfies SessionMessageInfo
test("keeps work and thinking above an undelivered steer", () => {
const source = [...messages.slice(0, 3), work]
const visible = visibleTimelineMessages(source, [steer])
expect(visible.map((message) => message.id)).toEqual(["msg_1", "msg_2", "msg_5", "msg_3"])
expect(source.map((message) => message.id)).toEqual(["msg_1", "msg_2", "msg_3", "msg_5"])
expect(visible[2]).toBe(work)
createRoot((dispose) => {
const projection = createTimelineProjection({
sessionMessages: () => visible,
status: () => ({ type: "busy" }),
showReasoningSummaries: () => false,
shellToolDefaultOpen: () => false,
editToolDefaultOpen: () => false,
pendingUserMessageIDs: () => new Set([steer.id]),
})
expect(projection.activeMessageID()).toBe("msg_1")
expect(projection.rows().map((row) => [row._tag, row.userMessageID])).toEqual([
["UserMessage", "msg_1"],
["AssistantPart", "msg_1"],
["Thinking", "msg_1"],
["TurnGap", "msg_3"],
["UserMessage", "msg_3"],
])
expect(
projection
.assistantMessagesByParent()
.get("msg_1")
?.map((message) => message.id),
).toEqual(["msg_2", "msg_5"])
expect(projection.assistantMessagesByParent().has(steer.id)).toBe(false)
dispose()
})
})
test("moves a queued input after existing work when changed to steer", () => {
const source = [...messages.slice(0, 3), work]
expect(visibleTimelineMessages(source, [{ ...steer, delivery: "queue" }]).map((message) => message.id)).toEqual([
"msg_1",
"msg_2",
"msg_5",
])
expect(visibleTimelineMessages(source, [steer]).map((message) => message.id)).toEqual([
"msg_1",
"msg_2",
"msg_5",
"msg_3",
])
const delivered = [messages[0], messages[1], work, messages[2]]
expect(visibleTimelineMessages(delivered, [])).toBe(delivered)
})
test("preserves steer order and excludes reverted steers", () => {
const source = [...messages, work]
const pending = [steer, { ...steer, id: "msg_4" }]
expect(visibleTimelineMessages(source, pending).map((message) => message.id)).toEqual([
"msg_1",
"msg_2",
"msg_5",
"msg_3",
"msg_4",
])
expect(visibleTimelineMessages(source, pending, "msg_4").map((message) => message.id)).toEqual([
"msg_1",
"msg_2",
"msg_3",
])
})
test("hides queued inputs until delivery", () => {
const pending = [
{
@@ -17,8 +17,19 @@ export function visibleTimelineMessages(
const queued = new Set(
pending.flatMap((item) => (item.type === "user" && item.delivery === "queue" ? [item.id] : [])),
)
if (queued.size === 0 && !revertMessageID) return messages
return messages.filter((message) => !queued.has(message.id) && (!revertMessageID || message.id < revertMessageID))
const steers = new Set(
pending.flatMap((item) => (item.type === "user" && item.delivery === "steer" ? [item.id] : [])),
)
if (queued.size === 0 && steers.size === 0 && !revertMessageID) return messages
const visible = messages.filter(
(message) => !queued.has(message.id) && (!revertMessageID || message.id < revertMessageID),
)
if (steers.size === 0) return visible
// Pending steers do not own assistant work until they are delivered.
return [
...visible.filter((message) => !steers.has(message.id)),
...visible.filter((message) => steers.has(message.id)),
]
}
export function timelineChildTitle(input: {
@@ -481,7 +481,6 @@ function MessageTimelineView(
const backgroundHintPresence = createAnimatedPresence(backgroundHintPartID, () => backgroundHintRef() ?? null)
return (
<VirtualizedTimeline
workspaceSession={workspaceSession}
bottomSpacer={
<Show when={backgroundHintPresence.present()}>
<div
@@ -1,8 +1,15 @@
import { createVirtualizer, defaultRangeExtractor, elementScroll, type VirtualItem } from "@tanstack/solid-virtual"
import {
createVirtualizer,
defaultRangeExtractor,
elementScroll,
type Range,
type VirtualItem,
} from "@tanstack/solid-virtual"
import { isScrollKeyTarget, scrollKey, scrollKeyOwner, ScrollView } from "@opencode-ai/ui/scroll-view"
import { TimelineRow } from "@opencode-ai/session-ui/timeline/projection"
import { useLanguage } from "@/runtime/i18n/language"
import {
batch,
createEffect,
createMemo,
createSignal,
@@ -55,7 +62,6 @@ type Input = {
type ViewProps = {
header: JSX.Element
bottomSpacer?: JSX.Element
workspaceSession: Accessor<boolean>
deferred: (row: TimelineRow.TimelineRow) => boolean
renderRow: (row: Accessor<TimelineRow.TimelineRow>, onSizeChange?: () => void) => JSX.Element
}
@@ -68,11 +74,12 @@ export function createTimelineVirtualizer(input: Input) {
const coldBottomMount = !initialMeasurements?.length && input.pinned()
const [listRoot, setListRoot] = createSignal<HTMLDivElement>()
const [toolOpen, setToolOpen] = createStore<Record<string, boolean | undefined>>(cached?.toolOpen ?? {})
const [renderOverscan, setRenderOverscan] = createSignal(initialMeasurements?.length || coldBottomMount ? 6 : 20)
const [overscan, setOverscan] = createSignal(2)
const rows = input.projection.rows
const rowByKey = input.projection.rowByKey
const knownKeys = new Set(rows().map(TimelineRow.key))
const addedKeys = new Set<string>()
const measuredElements = new WeakSet<Element>()
let touchStart: number | undefined
let pointerHeld = false
let maxScroll = 0
@@ -92,14 +99,19 @@ export function createTimelineVirtualizer(input: Input) {
initialOffset: () => (input.pinned() ? Number.MAX_SAFE_INTEGER : 0),
initialMeasurementsCache: initialMeasurements,
estimateSize: () => fallbackItemSize,
// Do not replace this with TanStack's default measurer: without a ResizeObserver entry,
// it returns the cached height instead of reading the element (TanStack/virtual#1183).
// Restored sessions, deferred tools, and rewrapped content can then keep stale heights;
// our fixed-height, overflow-clipped rows will hide their content. Keep observer entries
// on the cheap precomputed path, but make explicit measurements read the real height.
measureElement: (element, entry) => {
// A newly observed element gets a real ResizeObserver box before paint. Reuse
// 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) => {
const initial = !measuredElements.has(element)
measuredElements.add(element)
const box = entry?.borderBoxSize[0]
return box ? Math.round(box.blockSize) : element.offsetHeight
if (box) return Math.round(box.blockSize)
if (initial) {
const size = instance.itemSizeCache.get(instance.options.getItemKey(instance.indexFromElement(element)))
if (size !== undefined) return size
}
return element.offsetHeight
},
scrollToFn: (offset, options, instance) => {
if (virtualContent) virtualContent.style.height = `${instance.getTotalSize()}px`
@@ -131,41 +143,59 @@ export function createTimelineVirtualizer(input: Input) {
return input.showHeader() ? 64 : 0
},
paddingEnd: 64,
rangeExtractor: (range) => {
get rangeExtractor() {
const id = input.projection.activeMessageID()
const active = id ? (input.projection.messageLastRowIndex().get(id) ?? -1) : -1
const indexes = defaultRangeExtractor({ ...range, overscan: renderOverscan() })
return filterVirtualIndexes(
[...new Set([...indexes, ...(active < 0 ? [] : [active])])].sort((a, b) => a - b),
range.count,
)
const buffer = overscan()
return (range: Range) => {
const indexes = defaultRangeExtractor({ ...range, overscan: buffer })
return filterVirtualIndexes(
[...new Set([...indexes, ...(active < 0 ? [] : [active])])].sort((a, b) => a - b),
range.count,
)
}
},
})
const resizeItem = virtualizer.resizeItem
let resizeAnchorScheduled = false
// Rows measure asynchronously, so the last row can still hold its estimate when TanStack
// reconciles the end. Coalesce one correction per measurement batch, before paint.
const anchorResizedBottom = () => {
if (resizeAnchorScheduled) return
resizeAnchorScheduled = true
const pendingSizes = new Map<number, { key: string; size: number }>()
let resizeScheduled = false
// 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) => {
const row = rows()[index]
if (!row) return
const key = TimelineRow.key(row)
if (virtualizer.itemSizeCache.get(key) === size) {
pendingSizes.delete(index)
return
}
pendingSizes.set(index, { key, size })
if (resizeScheduled) return
resizeScheduled = true
queueMicrotask(() => {
resizeAnchorScheduled = false
resizeScheduled = false
if (!pendingSizes.size) return
const sizes = [...pendingSizes]
pendingSizes.clear()
batch(() => {
sizes.forEach(([index, value]) => {
const row = rows()[index]
if (row && TimelineRow.key(row) === value.key) resizeItem(index, value.size)
})
})
if (!input.pinned()) return
virtualizer.scrollToEnd()
const root = listRoot()
// Reopening a settled scroll-to-end operation can fight subsequent keyboard scrolling.
if (root && Math.abs(root.scrollHeight - root.clientHeight - root.scrollTop) > endEpsilon)
virtualizer.scrollToEnd()
})
}
virtualizer.resizeItem = (index, size) => {
resizeItem(index, size)
if (listRoot() && input.pinned()) anchorResizedBottom()
}
onCleanup(() => pendingSizes.clear())
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => {
// Prepended rows can resize more than once as deferred content mounts. Keep
// compensating while they remain entirely above the visible content fold.
if (addedKeys.has(String(item.key)))
return (
item.end <=
(instance.scrollOffset ?? 0) + instance.scrollAdjustments + instance.options.scrollMargin
)
return item.end <= (instance.scrollOffset ?? 0) + instance.scrollAdjustments + instance.options.scrollMargin
const first = instance.range?.startIndex
return first !== undefined && item.index < first
}
@@ -186,50 +216,41 @@ export function createTimelineVirtualizer(input: Input) {
})
})
let settleFrame: number | undefined
let overscanFrame: number | undefined
let overscanTimer: number | undefined
const expandOverscan = () => {
overscanFrame = requestAnimationFrame(() => {
overscanFrame = undefined
// Let the visible rows paint before building the normal interaction buffer.
overscanTimer = window.setTimeout(() => {
overscanTimer = undefined
setOverscan(20)
}, 0)
})
}
const pendingMeasurements = () =>
virtualizer.getVirtualItems().some((item) => !virtualizer.itemSizeCache.has(item.key))
const settleColdBottom = () => {
if (input.pinned()) virtualizer.scrollToEnd()
if (virtualContent?.querySelector(pendingMarkdown) || pendingMeasurements()) {
overscanFrame = requestAnimationFrame(settleColdBottom)
settleFrame = requestAnimationFrame(settleColdBottom)
return
}
overscanFrame = requestAnimationFrame(() => {
settleFrame = requestAnimationFrame(() => {
if (input.pinned()) virtualizer.scrollToEnd()
if (virtualContent?.querySelector(pendingMarkdown) || pendingMeasurements()) {
settleColdBottom()
return
}
overscanFrame = undefined
const content = virtualContent
if (!content) return
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
content.style.removeProperty("visibility")
return
}
const animation = ["animate-in", "fade-in", "duration-150"]
const clearAnimation = (event: AnimationEvent) => {
if (event.target !== content) return
content.removeEventListener("animationend", clearAnimation)
content.removeEventListener("animationcancel", clearAnimation)
content.classList.remove(...animation)
}
content.addEventListener("animationend", clearAnimation)
content.addEventListener("animationcancel", clearAnimation)
content.classList.add(...animation)
content.style.removeProperty("visibility")
settleFrame = undefined
virtualContent?.style.removeProperty("visibility")
expandOverscan()
})
}
onMount(() => {
overscanFrame = requestAnimationFrame(() => {
if (renderOverscan() < 20) setRenderOverscan(20)
if (!coldBottomMount) {
overscanFrame = undefined
return
}
settleColdBottom()
})
if (coldBottomMount) settleFrame = requestAnimationFrame(settleColdBottom)
if (!coldBottomMount) expandOverscan()
})
let measuredSessionKey = input.sessionKey()
@@ -256,11 +277,13 @@ export function createTimelineVirtualizer(input: Input) {
const handleListWheel = (event: WheelEvent & { currentTarget: HTMLDivElement }) => {
input.onUserScroll(event.target)
if (event.deltaY < 0) input.onUnpin()
setOverscan(20)
}
const handleListTouchStart = (event: TouchEvent) => {
input.onUserScroll(event.target)
touchStart = event.touches[0]?.clientY
setOverscan(20)
}
const handleListTouchMove = (event: TouchEvent & { currentTarget: HTMLDivElement }) => {
@@ -277,14 +300,19 @@ export function createTimelineVirtualizer(input: Input) {
const handleListPointerDown = (event: PointerEvent & { currentTarget: HTMLDivElement }) => {
input.onUserScroll(event.target)
pointerHeld = true
const release = () => {
pointerHeld = false
window.removeEventListener("pointerup", release)
window.removeEventListener("pointercancel", release)
}
window.addEventListener("pointerup", release)
window.addEventListener("pointercancel", release)
setOverscan(20)
}
const releasePointer = () => {
pointerHeld = false
}
onMount(() => {
window.addEventListener("pointerup", releasePointer)
window.addEventListener("pointercancel", releasePointer)
})
onCleanup(() => {
window.removeEventListener("pointerup", releasePointer)
window.removeEventListener("pointercancel", releasePointer)
})
const handleListKeyDown = (event: KeyboardEvent & { currentTarget: HTMLDivElement }) => {
const key = scrollKey(event)
@@ -293,6 +321,7 @@ export function createTimelineVirtualizer(input: Input) {
if (scrollKeyOwner(event.currentTarget, event.target, key) !== event.currentTarget) return
input.onUserScroll(event.currentTarget)
if (upwardKeys.has(key)) input.onUnpin()
setOverscan(20)
}
// Following resumes by arriving at the end, either by scrolling there or by content shrinking
@@ -371,7 +400,7 @@ export function createTimelineVirtualizer(input: Input) {
}
return (
<div class="relative w-full h-full min-w-0" data-workspace-session={props.workspaceSession() ? "" : undefined}>
<div class="relative w-full h-full min-w-0">
<div
class="absolute left-1/2 -translate-x-1/2 z-[60] pointer-events-none transition-all duration-200 ease-out"
classList={{
@@ -415,7 +444,6 @@ export function createTimelineVirtualizer(input: Input) {
<Show when={input.showHeader()}>{props.header}</Show>
<div
data-timeline-virtual-content
class="motion-reduce:animate-none"
ref={(element) => {
virtualContent = element
input.setContentRef(element)
@@ -447,7 +475,9 @@ export function createTimelineVirtualizer(input: Input) {
cache.delete(ownerSessionKey)
cache.set(ownerSessionKey, { measurements: virtualizer.takeSnapshot(), toolOpen: { ...toolOpen } })
while (cache.size > 16) cache.delete(cache.keys().next().value!)
if (settleFrame !== undefined) cancelAnimationFrame(settleFrame)
if (overscanFrame !== undefined) cancelAnimationFrame(overscanFrame)
if (overscanTimer !== undefined) window.clearTimeout(overscanTimer)
input.setScrollRef(undefined)
input.setRevealMessage?.(() => {})
input.setScrollToEnd?.(() => {})
@@ -27,6 +27,7 @@ export const SettingsExtensions: Component = () => {
const [mcpList, { refetch: refetchMcp }] = createResource(
() => serverSdk.connection.status() === "connected",
() => serverSdk.api.mcp.list().then((result) => result.data),
{ initialValue: [] },
)
const toggleMcp = useMcpToggle(() => undefined, refetchMcp)
const mcps = createMemo<McpRowItem[]>(() => {
@@ -44,6 +45,7 @@ export const SettingsExtensions: Component = () => {
const [pluginList] = createResource(
() => serverSdk.connection.status() === "connected",
() => serverSdk.api.plugin.list().then((result) => result.data),
{ initialValue: [] },
)
const plugins = createMemo<PluginRowItem[]>(() => pluginLabels(pluginList.latest ?? []).map((name) => ({ name })))
-6
View File
@@ -885,8 +885,6 @@
display: flex;
flex-direction: column;
gap: 0;
max-height: 480px;
overflow-y: auto;
padding: 20px;
border-radius: 6px;
background-color: var(--v2-background-bg-base);
@@ -1048,8 +1046,6 @@
}
.settings-workspaces-inventory [data-component="settings-list"] {
max-height: none;
overflow-y: visible;
padding: 14px;
}
@@ -1082,8 +1078,6 @@
}
.settings-workspaces-inventory [data-component="settings-list"] {
max-height: none;
overflow-y: visible;
padding: 14px;
}
@@ -97,10 +97,12 @@ export const ProjectSettingsExtensions: Component = () => {
const [globalPluginList] = createResource(
() => serverSDK.connection.status() === "connected",
() => serverSDK.api.plugin.list().then((result) => result.data),
{ initialValue: [] },
)
const [projectPluginList] = createResource(
() => (serverSDK.connection.status() === "connected" ? directorySDK().directory : undefined),
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
{ initialValue: [] },
)
const globalPlugins = createMemo(() => pluginLabels(globalPluginList.latest ?? []))
const projectPlugins = createMemo(() => {
@@ -60,6 +60,7 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
const projectQuery = useQuery(() => ({
queryKey: [serverSDK.scope, "settings-workspace-projects"] as const,
enabled: serverSDK.connection.status() === "connected",
queryFn: async () =>
Promise.all(
(await serverSDK.api.project.list()).map(async (project) => {
@@ -71,10 +72,9 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
),
refetchOnMount: "always",
}))
const workspaces = createMemo(() => workspaceInventory(projectQuery.data ?? []))
const projects = createMemo(() =>
(projectQuery.data ?? []).filter((project) => managedWorkspaceDirectories(project).length > 0),
)
const inventory = createMemo(() => (projectQuery.isPending ? [] : (projectQuery.data ?? [])))
const workspaces = createMemo(() => workspaceInventory(inventory()))
const projects = createMemo(() => inventory().filter((project) => managedWorkspaceDirectories(project).length > 0))
const projectName = (project: Project) => project.name || getFilename(project.worktree)
const projectOptions = createMemo(() => [
{ id: "all", label: language.t("settings.workspaces.filter.all") },
@@ -110,18 +110,18 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
workspaceDirectories().map((directory) => String(pathKey(directory))),
] as const,
queryFn: () => loadSessions(workspaceDirectories()),
enabled: workspaceDirectories().length > 0,
enabled: serverSDK.connection.status() === "connected" && workspaceDirectories().length > 0,
refetchOnMount: "always",
}))
const sessionsByWorkspace = createMemo(
() =>
new Map(
workspaces().map((workspace) => [
pathKey(workspace.directory),
sessionQuery.data ? sessionsForWorkspace(sessionQuery.data, workspace.directory) : [],
]),
),
)
const sessionsByWorkspace = createMemo(() => {
const sessions = sessionQuery.isPending ? [] : (sessionQuery.data ?? [])
return new Map(
workspaces().map((workspace) => [
pathKey(workspace.directory),
sessionsForWorkspace(sessions, workspace.directory),
]),
)
})
const workspaceSessions = (workspace: Workspace) => sessionsByWorkspace().get(pathKey(workspace.directory)) ?? []
const sessionCount = (workspace: Workspace) => {
if (sessionQuery.isPending) return language.t("session.messages.loading")
@@ -279,7 +279,9 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
<div class="settings-tab-body settings-workspaces">
<div class="settings-workspaces-toolbar">
<span class="settings-workspaces-count">
{language.plural("settings.workspaces.count", filtered().length)}
<Show when={!projectQuery.isPending && !projectQuery.isError}>
{language.plural("settings.workspaces.count", filtered().length)}
</Show>
</span>
<div class="settings-workspaces-toolbar-actions">
<Show when={projects().length > 1}>
@@ -332,7 +334,17 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
<div class="settings-workspaces-inventory">
<Show
when={filtered().length > 0}
fallback={<div class="settings-workspaces-empty">{language.t("settings.workspaces.empty")}</div>}
fallback={
<div class="settings-workspaces-empty">
{language.t(
projectQuery.isPending
? "common.loading"
: projectQuery.isError
? "common.requestFailed"
: "settings.workspaces.empty",
)}
</div>
}
>
<SettingsList>
<For each={filtered()}>
+1 -1
View File
@@ -36,7 +36,7 @@ export function AppRoutes() {
<SessionRouteFrame>
<Suspense
fallback={
<div class="flex min-h-0 flex-1 p-2">
<div class="flex min-h-0 flex-1 px-2 pb-2 pt-[var(--shell-top-inset,8px)]">
<SessionPanelFrame raised />
</div>
}
+8 -1
View File
@@ -43,6 +43,13 @@ export default function Layout(props: ParentProps) {
style={{
"padding-top": "env(safe-area-inset-top, 0px)",
"padding-bottom": "env(safe-area-inset-bottom, 0px)",
// The native Windows titlebar already includes the gap above the content panels.
"--shell-top-inset":
platform.platform === "desktop" &&
platform.os === "windows" &&
!(mobile() && preferences.general.mobileTitlebarPosition() === "bottom")
? "0px"
: "8px",
}}
>
<Titlebar
@@ -59,7 +66,7 @@ export default function Layout(props: ParentProps) {
<aside
ref={(element) => setState("tabsMount", element)}
data-slot="vertical-tabs-sidebar"
class="relative flex min-h-0 shrink-0 flex-col bg-v2-background-bg-deep px-2.5 py-2"
class="relative flex min-h-0 shrink-0 flex-col bg-v2-background-bg-deep px-2.5 pb-2 pt-[var(--shell-top-inset,8px)]"
style={{ width: `${state.tabsWidth}px` }}
>
<ResizeHandle
+2 -1
View File
@@ -27,6 +27,7 @@ import { newTabTooltipKeybind } from "@/shell/commands/tooltip-keybind"
import { TitlebarRightMount } from "@/shell/titlebar/right-slot"
const titlebarHeight = 36
const windowsTitlebarHeight = 44 // Includes the content inset; matches the native Windows overlay.
const minTitlebarZoom = 0.25
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
const macTrafficLightsBaseWidth = 84
@@ -59,7 +60,7 @@ export function Titlebar(props: {
const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom())
const minHeight = () => {
if (mac()) return `${titlebarHeight / zoom()}px`
if (windows()) return `env(titlebar-area-height, ${titlebarHeight / Math.min(titlebarZoom(), 1)}px)`
if (windows()) return `env(titlebar-area-height, ${windowsTitlebarHeight / Math.min(titlebarZoom(), 1)}px)`
return undefined
}
const windowsControlsWidth = () => `${windowsControlsBaseWidth / Math.max(titlebarZoom(), 1)}px`
@@ -0,0 +1,70 @@
import { expect, test } from "bun:test"
import path from "node:path"
test.each([
{ name: "local development", ci: "", build: "", built: false },
{ name: "local production build", ci: "", build: "1", built: true },
{ name: "CI production build", ci: "true", build: "", built: true },
{ name: "CI cannot opt into development", ci: "true", build: "0", built: true },
])("Playwright uses $name", ({ ci, build, built }) => {
const result = Bun.spawnSync({
cmd: [process.execPath, "-e", 'import config from "./playwright.config.ts"; console.log(JSON.stringify(config))'],
cwd: path.resolve(import.meta.dirname, ".."),
env: {
...process.env,
CI: ci,
PLAYWRIGHT_BUILD: build,
PLAYWRIGHT_BASE_URL: undefined,
PLAYWRIGHT_PORT: "4321",
PLAYWRIGHT_SERVER_HOST: "127.0.0.2",
PLAYWRIGHT_SERVER_PORT: "4322",
},
})
expect(result.exitCode).toBe(0)
const config = JSON.parse(result.stdout.toString())
expect(config.use.baseURL).toBe("http://127.0.0.1:4321")
expect(config.webServer.url).toBe(config.use.baseURL)
expect(config.webServer.command).toBe(
built
? "bun run build && bun run serve -- --host 127.0.0.1 --port 4321 --strictPort"
: "bun run dev -- --host 127.0.0.1 --port 4321 --strictPort",
)
expect(config.webServer.reuseExistingServer).toBe(!built)
expect(config.webServer.env).toEqual({
VITE_OPENCODE_SERVER_HOST: built ? "127.0.0.1" : "127.0.0.2",
VITE_OPENCODE_SERVER_PORT: built ? "4321" : "4322",
})
})
test.each([
"./playwright.config.ts",
"./e2e/performance/playwright.config.ts",
"./e2e/performance/playwright.uncapped.config.ts",
"./e2e/performance/timeline-stability/playwright.config.ts",
])("%s leaves an explicit external app unmanaged", (file) => {
const result = Bun.spawnSync({
cmd: [
process.execPath,
"-e",
`import config from ${JSON.stringify(file)}; console.log(JSON.stringify({ ...config, fixtureHost: process.env.PLAYWRIGHT_SERVER_HOST, fixturePort: process.env.PLAYWRIGHT_SERVER_PORT }))`,
],
cwd: path.resolve(import.meta.dirname, ".."),
env: { ...process.env, CI: "true", PLAYWRIGHT_BASE_URL: "http://127.0.0.1:4444" },
})
expect(result.exitCode).toBe(0)
const config = JSON.parse(result.stdout.toString())
expect(config.webServer).toBeUndefined()
expect(config.use.baseURL).toBe("http://127.0.0.1:4444")
expect(config.fixtureHost).toBe("127.0.0.1")
expect(config.fixturePort).toBe("4444")
})
test("Playwright rejects HTTPS targets unsupported by the API fixtures", () => {
const result = Bun.spawnSync({
cmd: [process.execPath, "-e", 'import "./playwright.config.ts"'],
cwd: path.resolve(import.meta.dirname, ".."),
env: { ...process.env, CI: "true", PLAYWRIGHT_BASE_URL: "https://e2e.example.com" },
})
expect(result.exitCode).not.toBe(0)
expect(result.stderr.toString()).toContain("E2E fixtures require an http:// app URL")
})
@@ -0,0 +1,52 @@
import { expect, test } from "bun:test"
import { createMemo, createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import { normalizeProviderList } from "@/runtime/server/global-sync/utils"
test("preserves an already normalized reactive catalog", () => {
const [store] = createStore({ catalog: { all: new Map<string, never>(), connected: [], default: {} } })
expect(normalizeProviderList(store.catalog)).toBe(store.catalog)
})
test("shares catalog snapshots and reacts to replacement lists", () => {
createRoot((dispose) => {
const provider = { id: "openai", name: "OpenAI", package: "@ai-sdk/openai", activation: "enabled" as const }
const model = {
id: "gpt-5",
modelID: "gpt-5",
providerID: "openai",
name: "GPT-5",
settings: {},
headers: {},
capabilities: { tools: true, input: ["text" as const], output: ["text" as const] },
variants: [],
time: { released: 1 },
cost: [],
status: "active" as const,
enabled: true,
limit: { context: 128_000, output: 8192 },
}
const [store, setStore] = createStore({ providers: [provider], models: [model] })
const first = createMemo(() => normalizeProviderList(store.providers, store.models))
const second = createMemo(() => normalizeProviderList(store.providers, store.models))
expect(first()).toBe(second())
const initial = first()
setStore("models", [{ ...model, name: "Renamed", limit: { context: 256_000, output: 16384 } }])
expect(first()).not.toBe(initial)
expect(first()).toBe(second())
expect(first().all.get("openai")?.models[model.id]).toMatchObject({
name: "Renamed",
limit: { context: 256_000, output: 16384 },
})
const renamed = first()
setStore("providers", [{ ...provider, name: "Custom OpenAI" }])
expect(first()).not.toBe(renamed)
expect(first()).toBe(second())
expect(first().all.get("openai")?.name).toBe("Custom OpenAI")
expect(normalizeProviderList(store.providers).all.get("openai")?.models).toEqual({})
setStore("models", [])
expect(first().all.get("openai")?.models).toEqual({})
expect(first().default).toEqual({})
dispose()
})
})
+1 -1
View File
@@ -27,7 +27,7 @@
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/pty": "0.1.10",
"@opencode-ai/pty": "0.1.12",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",
+1 -1
View File
@@ -118,7 +118,7 @@
"@ff-labs/fff-node": "0.10.5",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/pty": "0.1.10",
"@opencode-ai/pty": "0.1.12",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/util": "workspace:*",
+97 -31
View File
@@ -11,6 +11,9 @@ import { KeyedMutex } from "./effect/keyed-mutex.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { isDeepStrictEqual } from "node:util"
import { Durable } from "@opencode-ai/schema/durable-event-manifest"
import { SessionEvent } from "@opencode-ai/schema/session-event"
import type { SessionID } from "@opencode-ai/schema/session-id"
import { AbsolutePath } from "@opencode-ai/schema/schema"
export type Subscriber<D extends Event.Definition = Event.Definition> = (event: Event.Payload<D>) => Effect.Effect<void>
export type Unsubscribe = Effect.Effect<void>
@@ -119,6 +122,9 @@ export interface Subscribe {
/**
* Volatile live channel: every event published from now on, nothing before or
* across a disconnect. Consumers that need reliability combine it with `log`.
* With an ambient Location, delivery is restricted to that Location and global
* events. Unlocated Session events use the Session's owner at publication time.
* Session moves reach both the old and new Location, without changing the event.
*/
(): Stream.Stream<Event.Payload>
<D extends Event.Definition>(definition: D): Stream.Stream<Event.Payload<D>>
@@ -183,6 +189,7 @@ export function configured(options?: Options) {
// Deferred import: a static one would close the module cycle
// bus → location → project → bus and hit the node bindings in TDZ.
const { Location } = yield* Effect.promise(() => import("./location.js"))
const { SessionTable } = yield* Effect.promise(() => import("./session/sql.js"))
const pubsub = {
live: yield* PubSub.unbounded<Event.Payload>(),
durable: new Map<string, Set<PubSub.PubSub<void>>>(),
@@ -194,6 +201,64 @@ export function configured(options?: Options) {
const { db } = yield* Database.Service
const logReadPageSize = options?.logReadPageSize ?? 512
const persist = options?.persist ?? false
const sessions = new Map<SessionID, Location.Ref>()
// Keep routing separate from the public event, and retain its snapshot
// while a slow subscriber drains events queued before a move or deletion.
const routes = new WeakMap<Event.Payload, readonly Location.Ref[]>()
const isSessionEvent = (event: Event.Payload): event is SessionEvent.Event =>
Object.hasOwn(SessionEvent.All.cases, event.type)
const prepareRoutes = Effect.fnUntraced(function* (events: readonly Event.Payload[]) {
const updates = new Map<SessionID, Location.Ref | undefined>()
const resolved = new Map<Event.Payload, readonly Location.Ref[]>()
for (const event of events) {
if (!isSessionEvent(event)) continue
const id = event.data.sessionID
if (event.type === "session.created") {
updates.set(id, event.data.location)
resolved.set(event, [event.location ?? event.data.location])
continue
}
if (event.location && event.type !== "session.forked" && event.type !== "session.moved") {
if (event.type === "session.deleted") updates.set(id, undefined)
continue
}
const owner = event.type === "session.forked" ? event.data.parentID : id
let ref = updates.has(owner) ? updates.get(owner) : sessions.get(owner)
if (!ref && !updates.has(owner)) {
const row = yield* db
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, owner))
.get()
.pipe(Effect.orDie)
ref = row
? { directory: AbsolutePath.make(row.directory), workspaceID: row.workspaceID ?? undefined }
: undefined
updates.set(owner, ref)
}
if (event.type === "session.moved") {
// Both owners need the transition, even if the producer supplied
// an envelope location. Later events use only the destination.
updates.set(id, event.data.location)
resolved.set(event, ref ? [ref, event.data.location] : [event.data.location])
continue
}
if (event.type === "session.forked") updates.set(id, ref)
resolved.set(event, event.location ? [event.location] : ref ? [ref] : [])
if (event.type === "session.deleted") updates.set(id, undefined)
}
// Apply only after the projection transaction commits. A failed move
// must not redirect events away from the Session's actual location.
return () => {
for (const [id, ref] of updates) {
if (ref) sessions.set(id, ref)
else sessions.delete(id)
}
for (const [event, ref] of resolved) routes.set(event, ref)
}
})
const getOrCreate = (definition: Event.Definition) =>
Effect.gen(function* () {
@@ -335,6 +400,7 @@ export function configured(options?: Options) {
...event,
durable: { aggregateID, seq, version: durable.version },
} as Event.Payload
const route = yield* prepareRoutes([committed])
for (const projector of list) {
yield* projector(committed)
}
@@ -366,12 +432,13 @@ export function configured(options?: Options) {
])
.run()
.pipe(Effect.orDie)
return { aggregateID, seq }
return { aggregateID, seq, event: committed, route }
}),
{ behavior: "immediate" },
)
.pipe(Effect.orDie)
if (committed) {
committed.route()
yield* Effect.forEach(
pubsub.durable.get(committed.aggregateID) ?? [],
(wake) => PubSub.publish(wake, undefined),
@@ -409,15 +476,14 @@ export function configured(options?: Options) {
Effect.gen(function* () {
const committed = yield* commitDurableEvent(definition, event as Event.Payload, undefined, commit)
if (!committed) return event
event = {
...event,
durable: envelope(committed.aggregateID, committed.seq, definition.durable.version),
}
event = committed.event as Event.Payload<D>
yield* notify(event as Event.Payload, true)
return event
}),
)
}
const route = yield* prepareRoutes([event as Event.Payload])
route()
yield* notify(event as Event.Payload, false)
return event
})
@@ -527,7 +593,11 @@ export function configured(options?: Options) {
.pipe(Effect.orDie)
const firstSeq = (row?.seq ?? -1) + 1
const finalSeq = firstSeq + payloads.length - 1
const result = new Array<Event.Payload>()
const queued = payloads.map((item, index) => ({
...item.event,
durable: envelope(aggregateID, firstSeq + index, item.definition.durable.version),
}))
const route = yield* prepareRoutes(queued)
const rows = new Array<typeof EventTable.$inferInsert>()
const ids = new Set<Event.ID>()
for (const [index, item] of payloads.entries()) {
@@ -559,10 +629,7 @@ export function configured(options?: Options) {
}),
)
}
const event = {
...item.event,
durable: envelope(aggregateID, seq, item.definition.durable.version),
} as Event.Payload
const event = queued[index]
for (const projector of projectors.get(
versionedType(item.definition.type, item.definition.durable.version),
) ?? []) {
@@ -578,7 +645,6 @@ export function configured(options?: Options) {
type: versionedType(item.definition.type, item.definition.durable.version),
data: encoded,
})
result.push(event)
}
yield* db
.insert(EventSequenceTable)
@@ -587,11 +653,12 @@ export function configured(options?: Options) {
.run()
.pipe(Effect.orDie)
if (persist) yield* db.insert(EventTable).values(rows).run().pipe(Effect.orDie)
return result
return { events: queued, route }
}),
{ behavior: "immediate" },
)
.pipe(Effect.orDie)
committed.route()
yield* Effect.forEach(
pubsub.durable.get(aggregateID) ?? [],
(wake) => PubSub.publish(wake, undefined),
@@ -599,8 +666,8 @@ export function configured(options?: Options) {
discard: true,
},
)
yield* Effect.forEach(committed, (event) => notify(event, true), { discard: true })
return committed as PublishResult<I>
yield* Effect.forEach(committed.events, (event) => notify(event, true), { discard: true })
return committed.events as PublishResult<I>
}),
),
)
@@ -632,13 +699,7 @@ export function configured(options?: Options) {
strictOwner: options?.strictOwner,
})
if (committed && options?.publish) {
yield* notify(
{
...payload,
durable: envelope(committed.aggregateID, committed.seq, definition.durable.version),
},
true,
)
yield* notify(committed.event, true)
}
}),
)
@@ -653,7 +714,10 @@ export function configured(options?: Options) {
yield* db.delete(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).run()
}),
)
.pipe(Effect.orDie)
.pipe(
Effect.tap(() => Effect.sync(() => sessions.delete(aggregateID as SessionID))),
Effect.orDie,
)
}
function claim(aggregateID: string, ownerID: string) {
@@ -671,15 +735,17 @@ export function configured(options?: Options) {
Effect.map((location) =>
Option.match(location, {
onNone: () => stream,
onSome: (location) =>
stream.pipe(
Stream.filter(
(event) =>
!event.location ||
(event.location.directory === location.directory &&
event.location.workspaceID === location.workspaceID),
),
),
onSome: (location) => {
const matches = (ref: Location.Ref) =>
ref.directory === location.directory && ref.workspaceID === location.workspaceID
return stream.pipe(
Stream.filter((event) => {
const refs = routes.get(event)
if (refs) return refs.some(matches)
return !event.location || matches(event.location)
}),
)
},
}),
),
),
+2
View File
@@ -507,9 +507,11 @@ const layer = Layer.effect(
from: TreeID
to: TreeID
}) {
// Undo needs both paths of a rename, not only its destination.
return (yield* repositoryOperation("list_files", input.repository, [
"diff",
"--name-only",
"--no-renames",
"-z",
input.from,
input.to,
+1 -1
View File
@@ -55,7 +55,7 @@ export interface Interface extends State.Transformable<Draft> {
export class Service extends Context.Service<Service, Interface>()("@opencode/Image") {}
const layer = Layer.effect(
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const state = State.create<Limits, Draft>({
+1 -8
View File
@@ -402,14 +402,7 @@ export const make = Effect.gen(function* () {
})
const pendingBackground: Interface["pendingBackground"] = Effect.gen(function* () {
const recovered: Background[] = []
let after: string | undefined
do {
const page = yield* kv.scan({ prefix: backgroundPrefix, after })
recovered.push(...Array.filterMap(page.entries, (entry) => decodeBackground(entry.value)))
after = page.next
} while (after)
return recovered
return Array.filterMap(yield* kv.scanAll(backgroundPrefix), (entry) => decodeBackground(entry.value))
}).pipe(Effect.withSpan("Job.pendingBackground"))
const completeBackground: Interface["completeBackground"] = Effect.fn("Job.completeBackground")((notificationID) =>
+33 -20
View File
@@ -29,6 +29,7 @@ export interface Interface {
readonly set: (key: string, value: Value) => Effect.Effect<void>
readonly remove: (key: string) => Effect.Effect<void>
readonly scan: (options: ScanOptions) => Effect.Effect<ScanResult>
readonly scanAll: (prefix: string) => Effect.Effect<readonly Entry[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/KV") {}
@@ -37,6 +38,28 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const db = (yield* Database.Service).db
const scan: Interface["scan"] = Effect.fn("KV.scan")(function* (options) {
const limit = Number.isNaN(options.limit) ? 100 : Math.min(Math.max(Math.floor(options.limit ?? 100), 1), 1000)
const end = prefixEnd(options.prefix)
const rows = yield* db
.select({ key: KVTable.key, value: KVTable.value })
.from(KVTable)
.where(
and(
options.prefix === "" ? undefined : gte(KVTable.key, options.prefix),
end === undefined ? undefined : lt(KVTable.key, end),
options.after === undefined ? undefined : gt(KVTable.key, options.after),
),
)
.orderBy(asc(KVTable.key))
.limit(limit + 1)
.all()
.pipe(Effect.orDie)
const entries = rows.slice(0, limit)
if (rows.length <= limit) return { entries }
return { entries, next: entries[entries.length - 1].key }
})
return Service.of({
get: Effect.fn("KV.get")(function* (key) {
return (yield* db
@@ -57,26 +80,16 @@ const layer = Layer.effect(
remove: Effect.fn("KV.remove")(function* (key) {
yield* db.delete(KVTable).where(eq(KVTable.key, key)).run().pipe(Effect.orDie)
}),
scan: Effect.fn("KV.scan")(function* (options) {
const limit = Number.isNaN(options.limit) ? 100 : Math.min(Math.max(Math.floor(options.limit ?? 100), 1), 1000)
const end = prefixEnd(options.prefix)
const rows = yield* db
.select({ key: KVTable.key, value: KVTable.value })
.from(KVTable)
.where(
and(
options.prefix === "" ? undefined : gte(KVTable.key, options.prefix),
end === undefined ? undefined : lt(KVTable.key, end),
options.after === undefined ? undefined : gt(KVTable.key, options.after),
),
)
.orderBy(asc(KVTable.key))
.limit(limit + 1)
.all()
.pipe(Effect.orDie)
const entries = rows.slice(0, limit)
if (rows.length <= limit) return { entries }
return { entries, next: entries[entries.length - 1].key }
scan,
scanAll: Effect.fn("KV.scanAll")(function* (prefix) {
const entries: Entry[] = []
let after: string | undefined
do {
const page = yield* scan({ prefix, after, limit: 1000 })
entries.push(...page.entries)
after = page.next
} while (after !== undefined)
return entries
}),
})
}),
+4 -1
View File
@@ -207,7 +207,10 @@ export const connect = Effect.fnUntraced(function* (
}
if (!URL.canParse(config.url))
return yield* new ConnectError({ server, message: `Invalid MCP URL for "${server}"` })
return new StreamableHTTPClientTransport(new URL(config.url), {
// Prefer raw tools for our Code Mode without changing the configured URL used for OAuth identity.
const url = new URL(config.url)
if (config.codemode !== false && !url.searchParams.has("codemode")) url.searchParams.set("codemode", "false")
return new StreamableHTTPClientTransport(url, {
requestInit: config.headers ? { headers: config.headers } : undefined,
authProvider,
})
+4 -4
View File
@@ -100,6 +100,10 @@ export function merge(...rulesets: Permission.Ruleset[]): Permission.Ruleset {
return rulesets.flat()
}
export function relevant(input: Pick<Request, "action">, rules: Permission.Ruleset) {
return rules.filter((rule) => Wildcard.match(input.action, rule.action))
}
export interface Interface {
readonly ask: (input: AssertInput) => Effect.Effect<AskResult, SessionErrors.NotFoundError>
readonly assert: (input: AssertInput) => Effect.Effect<void, Error | SessionErrors.NotFoundError>
@@ -161,10 +165,6 @@ const layer = Layer.effect(
return input.resources.some((resource) => evaluate(input.action, resource, rules).effect === "deny")
}
function relevant(input: AssertInput, rules: Permission.Ruleset) {
return rules.filter((rule) => Wildcard.match(input.action, rule.action))
}
const evaluateInput = Effect.fnUntraced(function* (input: AssertInput) {
const rules = yield* configured(input.sessionID, input.agent)
if (denied(input, rules)) return { effect: "deny" as const, rules }
+38
View File
@@ -0,0 +1,38 @@
export * as Permissions from "./permissions.js"
import { Effect } from "effect"
import { Permission } from "./permission.js"
import type { SessionSchema } from "./session/schema.js"
import { Source } from "./source.js"
export interface Interface {
readonly visibility: Source.Interface<Permission.Ruleset>
readonly ask: (
session: SessionSchema.Info,
request: Omit<Permission.AssertInput, "sessionID" | "agent">,
) => Effect.Effect<void, Permission.Error | Permission.DeclinedError>
}
export const allowAll: Interface = {
visibility: Source.constant([{ action: "*", resource: "*", effect: "allow" }]),
ask: () => Effect.void,
}
export function rules(source: Source.Value<Permission.Ruleset>): Interface {
const visibility = Source.from(source)
return {
visibility,
ask: Effect.fn("Permissions.ask")(function* (session, request) {
const rules = yield* visibility.get(session)
if (
request.resources.every((resource) => Permission.evaluate(request.action, resource, rules).effect === "allow")
)
return
return yield* new Permission.BlockedError({
rules: Permission.relevant(request, rules),
permission: request.action,
resources: request.resources,
})
}),
}
}
+1 -1
View File
@@ -60,7 +60,7 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginHooks") {}
const layer = Layer.effect(
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const callbacks = new Map<string, Entry[]>()
+3 -28
View File
@@ -73,6 +73,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
return {
app,
location: locationInfo(),
options: {},
agent: {
get: (input) => {
@@ -288,26 +289,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
if (ref && !isCurrentLocation(ref)) return runtime.location.mcp.list(ref)
return response(mcp.servers())
},
add: (input) => {
const ref = locationRef(input)
if (ref && !isCurrentLocation(ref)) return runtime.location.mcp.add(ref, input.server, input.config)
return mcp.add(input.server, input.config)
},
remove: (input) => {
const ref = locationRef(input)
if (ref && !isCurrentLocation(ref)) return runtime.location.mcp.remove(ref, input.server)
return mcp.remove(input.server)
},
connect: (input) => {
const ref = locationRef(input)
if (ref && !isCurrentLocation(ref)) return runtime.location.mcp.connect(ref, input.server)
return mcp.connect(input.server)
},
disconnect: (input) => {
const ref = locationRef(input)
if (ref && !isCurrentLocation(ref)) return runtime.location.mcp.disconnect(ref, input.server)
return mcp.disconnect(input.server)
},
reload: mcp.reload,
transform: (callback) =>
mcp.transform((draft) => {
@@ -377,14 +358,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
hook: (name, callback) => hooks.register("shell", name, callback),
},
tool: {
transform: (callback) =>
tools
.transform((draft) =>
callback({
add: (tool) => draft.add(tool),
}),
)
.pipe(Effect.as({ dispose: Effect.void })),
transform: tools.transform,
reload: tools.reload,
hook: (name, callback) => hooks.register("tool", name, callback),
},
vcs: {
@@ -4,7 +4,7 @@ import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect } from "effect"
// These servers provide Code Mode, so expose them directly instead of nesting them inside OpenCode Code Mode.
const urls = [/^https:\/\/mcp\.cloudflare\.com\/mcp$/, /^https:\/\/executor\.sh\/[^/]+\/mcp$/]
const urls = [/^https:\/\/executor\.sh\/[^/]+\/mcp$/]
export const Plugin = define({
id: "opencode.mcp.codemode.exclusion",
-17
View File
@@ -2,7 +2,6 @@ export * as PluginRuntime from "./runtime.js"
import { Context, Effect, Layer } from "effect"
import { Agent } from "../agent.js"
import { Mcp } from "@opencode-ai/schema/mcp"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Job } from "../job.js"
import { Location } from "../location.js"
@@ -40,10 +39,6 @@ export interface Interface {
readonly list: (
ref: Location.Ref,
) => Effect.Effect<{ readonly location: Location.Info; readonly data: MCP.ServerInfo[] }, unknown>
readonly add: (ref: Location.Ref, server: string, config: Mcp.ServerConfig) => Effect.Effect<void, unknown>
readonly remove: (ref: Location.Ref, server: string) => Effect.Effect<void, unknown>
readonly connect: (ref: Location.Ref, server: string) => Effect.Effect<void, unknown>
readonly disconnect: (ref: Location.Ref, server: string) => Effect.Effect<void, unknown>
}
}
}
@@ -101,10 +96,6 @@ export const layerWithCell = (cell: Cell) =>
},
mcp: {
list: (ref) => require(cell, (runtime) => runtime.location.mcp.list(ref)),
add: (ref, server, config) => require(cell, (runtime) => runtime.location.mcp.add(ref, server, config)),
remove: (ref, server) => require(cell, (runtime) => runtime.location.mcp.remove(ref, server)),
connect: (ref, server) => require(cell, (runtime) => runtime.location.mcp.connect(ref, server)),
disconnect: (ref, server) => require(cell, (runtime) => runtime.location.mcp.disconnect(ref, server)),
},
},
}),
@@ -149,14 +140,6 @@ export const providerLayerWithCell = (cell: Cell) =>
data: yield* mcp.servers(),
}
}).pipe(Effect.provide(locations.get(ref))),
add: (ref, server, config) =>
MCP.Service.use((mcp) => mcp.add(server, config)).pipe(Effect.provide(locations.get(ref))),
remove: (ref, server) =>
MCP.Service.use((mcp) => mcp.remove(server)).pipe(Effect.provide(locations.get(ref))),
connect: (ref, server) =>
MCP.Service.use((mcp) => mcp.connect(server)).pipe(Effect.provide(locations.get(ref))),
disconnect: (ref, server) =>
MCP.Service.use((mcp) => mcp.disconnect(server)).pipe(Effect.provide(locations.get(ref))),
},
},
}
+10 -6
View File
@@ -46,6 +46,15 @@ export interface Resolved {
readonly vcsBackend?: string
}
export function markerless(directory: AbsolutePath): Resolved {
return {
id: ID.make(Hash.fast(`directory:${directory}`)),
directory,
canonical: directory,
vcs: undefined,
}
}
// Keep this filesystem-only; permission checks use it and should not execute VCS commands.
export const root = Effect.fn("Project.root")(function* (
fs: FSUtil.Interface,
@@ -361,12 +370,7 @@ const layer = Layer.effect(
})
}
return yield* persist({
id: ID.make(Hash.fast(`directory:${directory}`)),
directory,
canonical: directory,
vcs: undefined,
})
return yield* persist(markerless(directory))
})
return Service.of({ list, update, resolve })
+116 -67
View File
@@ -1,5 +1,6 @@
export * as Session from "./session.js"
export * from "./session/schema.js"
export type { OpenInput, Handle } from "./session/capabilities.js"
import { Cause, Effect, Layer, Schema, Context, RcMap, Stream, Scope } from "effect"
import { ListAnchor } from "@opencode-ai/schema/session"
@@ -55,6 +56,8 @@ import { fileURLToPath } from "url"
import { SessionEnvironment } from "./session/environment.js"
import { SessionHistory } from "./session/history.js"
import { InstructionEntry } from "./session/instruction-entry.js"
import { SessionResolve } from "./session/resolve.js"
import type { SessionCapabilities } from "./session/capabilities.js"
// get project -> project.locations
//
@@ -187,6 +190,7 @@ export const MessageNotFoundError = SessionRevert.MessageNotFoundError
export type MessageNotFoundError = SessionRevert.MessageNotFoundError
export interface Interface {
readonly open: (input: SessionCapabilities.OpenInput) => Effect.Effect<SessionCapabilities.Handle>
readonly list: (input?: ListInput) => Effect.Effect<{
readonly data: SessionSchema.Info[]
}>
@@ -341,6 +345,7 @@ const layer = Layer.effect(
const execution = yield* SessionExecution.Service
const store = yield* SessionStore.Service
const locations = yield* LocationServiceMap.Service
const resolve = yield* SessionResolve.Service
const fs = yield* FSUtil.Service
const jobs = yield* Job.Service
const environments = yield* SessionEnvironment.Service
@@ -348,6 +353,9 @@ const layer = Layer.effect(
const activeShells = new Set<SessionSchema.ID>()
const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
const closeTransport = Effect.fn("Session.closeTransport")(function* (session: SessionSchema.Info) {
const resolved = yield* resolve.resolve(session)
if (resolved.status === "attached") return yield* resolved.capabilities.transport.close(session.id)
if (resolved.status === "owned-detached") return
const location = Location.Ref.make({
directory: session.location.directory,
workspaceID: session.location.workspaceID,
@@ -356,7 +364,7 @@ const layer = Layer.effect(
yield* SessionModelTransport.Service.use((transport) => transport.close(session.id)).pipe(
Effect.provide(locations.get(location)),
)
})
}, Effect.scoped)
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
const persistProject = (project: Project.Resolved) => upsertProject(db, project).pipe(Effect.orDie)
@@ -383,61 +391,90 @@ const layer = Layer.effect(
}),
)
const create = Effect.fn("Session.create")(function* (input: CreateInput, resolved?: Project.Resolved) {
const sessionID = input.id ?? SessionSchema.ID.create()
const recorded = yield* store.get(sessionID)
if (recorded) return recorded
const parent = input.parentID ? yield* store.get(input.parentID) : undefined
if (input.parentID && parent === undefined) return yield* new NotFoundError({ sessionID: input.parentID })
const location = parent?.location ?? input.location
if (location === undefined)
return yield* Effect.die(new Error("Session.create requires either location or an existing parentID"))
const project = resolved ?? (yield* projects.resolve(location.directory))
yield* persistProject(project)
const projected = yield* bus
.publish(
SessionEvent.Created,
{
sessionID,
slug: Slug.create(),
version: app.version,
projectID: project.id,
parentID: input.parentID,
location,
subpath: RelativePath.make(path.relative(project.directory, location.directory).replaceAll("\\", "/")),
title: input.title,
agent: input.agent,
model: input.model
? {
id: Model.ID.make(input.model.id),
providerID: input.model.providerID,
variant: input.model.variant,
}
: undefined,
},
{ location },
)
.pipe(
Effect.as({ type: "created" } as const),
Effect.catchDefect((defect) => {
if (!(defect instanceof SessionProjector.SessionAlreadyProjected)) {
return Effect.die(defect)
}
// Concurrent creation lost the projection race. The existing Session identity wins.
return store
.get(sessionID)
.pipe(
Effect.flatMap((session) =>
session ? Effect.succeed({ type: "existing", session } as const) : Effect.die(defect),
),
)
}),
)
if (projected.type === "existing") return projected.session
// TODO: Restore recorded sessions onto replacement synchronized workspaces in a future API slice.
return yield* result.get(sessionID).pipe(Effect.orDie)
})
const result = Service.of({
create: Effect.fn("Session.create")(function* (input) {
const sessionID = input.id ?? SessionSchema.ID.create()
const recorded = yield* store.get(sessionID)
if (recorded) return recorded
const parent = input.parentID ? yield* store.get(input.parentID) : undefined
if (input.parentID && parent === undefined) return yield* new NotFoundError({ sessionID: input.parentID })
const location = parent?.location ?? input.location
if (location === undefined)
return yield* Effect.die(new Error("Session.create requires either location or an existing parentID"))
const project = yield* projects.resolve(location.directory)
yield* persistProject(project)
const projected = yield* bus
.publish(
SessionEvent.Created,
{
sessionID,
slug: Slug.create(),
version: app.version,
projectID: project.id,
parentID: input.parentID,
location,
subpath: RelativePath.make(path.relative(project.directory, location.directory).replaceAll("\\", "/")),
title: input.title,
agent: input.agent,
model: input.model
? {
id: Model.ID.make(input.model.id),
providerID: input.model.providerID,
variant: input.model.variant,
}
: undefined,
},
{ location },
)
.pipe(
Effect.as({ type: "created" } as const),
Effect.catchDefect((defect) => {
if (!(defect instanceof SessionProjector.SessionAlreadyProjected)) {
return Effect.die(defect)
}
// Concurrent creation lost the projection race. The existing Session identity wins.
return store
.get(sessionID)
.pipe(
Effect.flatMap((session) =>
session ? Effect.succeed({ type: "existing", session } as const) : Effect.die(defect),
),
)
}),
)
if (projected.type === "existing") return projected.session
// TODO: Restore recorded sessions onto replacement synchronized workspaces in a future API slice.
return yield* result.get(sessionID).pipe(Effect.orDie)
}),
create,
open: Effect.fn("Session.open")((input) =>
Effect.uninterruptible(
Effect.gen(function* () {
const directory = AbsolutePath.make(process.cwd())
// Transitional placement is unused by supplied capabilities, but listings group
// these Sessions under the deterministic cwd-derived project. Adoption never moves it.
const session = yield* resolve.own(
create(
{ id: input.id, title: input.title, location: Location.Ref.make({ directory }) },
Project.markerless(directory),
).pipe(Effect.orDie),
)
const close = yield* resolve.attach(session.id, input)
return {
id: session.id,
prompt: (prompt) =>
Effect.gen(function* () {
const admitted = yield* result.prompt({ ...prompt, sessionID: session.id, resume: false })
if (prompt.resume !== false) yield* result.resume(session.id)
return admitted
}),
resume: () => result.resume(session.id),
interrupt: (options) => result.interrupt(session.id, options),
close,
} satisfies SessionCapabilities.Handle
}),
),
),
fork: Effect.fn("Session.fork")(function* (input) {
const parent = yield* result.get(input.sessionID)
const boundary = yield* db
@@ -510,6 +547,7 @@ const layer = Layer.effect(
yield* Effect.forEach(children.data, (child) => result.remove(child.id), { concurrency: 1, discard: true })
yield* environments.clear(sessionID)
yield* bus.publish(SessionEvent.Deleted, { sessionID })
yield* resolve.remove(sessionID)
yield* bus.remove(sessionID)
}),
list: Effect.fn("Session.list")(function* (input = {}) {
@@ -648,16 +686,24 @@ const layer = Layer.effect(
if (session.revert) yield* SessionRevert.commit(session).pipe(Effect.provideService(Bus.Service, bus))
// Resolved lazily so prompt admission only boots location services when an
// image attachment actually needs the resizer.
const image = Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* Image.Service
}).pipe(Effect.provide(locations.get(session.location)))
const skills = Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* Skill.Service
}).pipe(Effect.provide(locations.get(session.location)))
const resolved = input.files?.length || input.skills?.length ? yield* resolve.resolve(session) : undefined
// TODO: typed unavailable-operation errors belong to the capability-gated operations phase.
if (resolved?.status === "owned-detached") return yield* SessionResolve.unavailable(session.id)
const capabilities = resolved?.status === "attached" ? resolved.capabilities : undefined
const image = capabilities
? Effect.succeed(capabilities.image)
: Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* Image.Service
}).pipe(Effect.provide(locations.get(session.location)))
const skills = capabilities
? Effect.undefined
: Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* Skill.Service
}).pipe(Effect.provide(locations.get(session.location)))
const prompt = yield* resolvePrompt(
{ text: input.text, files: input.files, agents: input.agents, skills: input.skills },
image,
@@ -690,7 +736,7 @@ const layer = Layer.effect(
}
return admitted
}),
),
).pipe(Effect.scoped),
),
generate: Effect.fn("Session.generate")(function* (input) {
const session = yield* result.get(input.sessionID)
@@ -778,6 +824,7 @@ const layer = Layer.effect(
}),
skill: Effect.fn("Session.skill")(function* (input) {
const session = yield* result.get(input.sessionID)
if (resolve.status(session.id) !== "unowned") return yield* new SkillNotFoundError({ skill: input.skill })
const skills = yield* Skill.Service.pipe(Effect.provide(locations.get(session.location)))
const skill = yield* skills.get(input.skill)
if (!skill) return yield* new SkillNotFoundError({ skill: input.skill })
@@ -1021,7 +1068,7 @@ function synthesizeTerminalShellInfo(started: ShellSchema.Info): ShellSchema.Inf
const resolvePrompt = Effect.fn("Session.resolvePrompt")(function* (
input: PromptInput.Prompt,
image: Effect.Effect<Image.Interface>,
skills: Effect.Effect<Skill.Interface>,
skills: Effect.Effect<Skill.Interface | undefined>,
) {
const fs = yield* FSUtil.Service
const files = input.files
@@ -1031,6 +1078,7 @@ const resolvePrompt = Effect.fn("Session.resolvePrompt")(function* (
const selected = yield* Effect.gen(function* () {
if (!requested?.length) return undefined
const skillService = yield* skills
if (!skillService) return yield* new SkillNotFoundError({ skill: requested[0].id })
const prepared = new Map<Skill.ID, Skill.Name>()
return yield* Effect.forEach(requested, (attachment) =>
Effect.gen(function* () {
@@ -1201,6 +1249,7 @@ export const node = makeGlobalNode({
Project.node,
SessionExecution.node,
SessionStore.node,
SessionResolve.node,
LocationServiceMap.node,
SessionProjector.node,
FSUtil.node,
+38
View File
@@ -0,0 +1,38 @@
export * as SessionCapabilities from "./capabilities.js"
import type { Effect } from "effect"
import type { Instructions } from "../instructions/index.js"
import type { Permissions } from "../permissions.js"
import type { Source } from "../source.js"
import type { Tool } from "../tool.js"
import type { Session } from "../session.js"
import type { SessionRunner } from "./runner/index.js"
import type { SessionRunnerModel } from "./runner/model.js"
import type { SessionSchema } from "./schema.js"
export interface OpenInput {
readonly id?: SessionSchema.ID
readonly title?: string
readonly model: Source.Value<SessionRunnerModel.Resolved, SessionRunnerModel.Error>
readonly tools?: Source.Value<ReadonlyArray<Tool.Info>>
readonly instructions?: Source.Value<ReadonlyArray<string> | Instructions.Unavailable>
readonly permissions?: Permissions.Interface
readonly system?: Source.Value<string | Instructions.Unavailable>
readonly limits?: Source.Value<{ readonly steps?: number }>
/** Called after replacement or host teardown, once all in-flight work has settled. */
readonly retire?: () => Effect.Effect<void>
}
export interface Handle {
readonly id: SessionSchema.ID
readonly prompt: (
input: Omit<Parameters<Session.Interface["prompt"]>[0], "sessionID">,
) => Effect.Effect<
Effect.Success<ReturnType<Session.Interface["prompt"]>>,
Effect.Error<ReturnType<Session.Interface["prompt"]>> | SessionRunner.RunError
>
readonly resume: () => ReturnType<Session.Interface["resume"]>
readonly interrupt: (options?: { readonly continue?: boolean }) => Effect.Effect<boolean>
/** Releases this open's capabilities after settlement without interrupting or deleting the Session. */
readonly close: () => Effect.Effect<void>
}
+14 -10
View File
@@ -8,9 +8,10 @@ import { Bus } from "../bus.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { llmClient } from "../effect/app-node-platform.js"
import { SessionEvent } from "./event.js"
import type { SessionContext } from "./context.js"
import type { SessionMessage } from "./message.js"
import { SessionModelRequest } from "./model-request.js"
import { SessionRunnerModel } from "./runner/model.js"
import type { SessionModelRequest } from "./model-request.js"
import type { SessionRunnerModel } from "./runner/model.js"
import { SessionSchema } from "./schema.js"
import { toSessionError } from "./to-session-error.js"
import { Token } from "../util/token.js"
@@ -69,14 +70,13 @@ type Dependencies = {
readonly llm: {
readonly stream: (request: LLMRequest, options?: StreamOptions) => Stream.Stream<LLMEvent, AIError>
}
readonly models: SessionRunnerModel.Interface
readonly modelRequests: SessionModelRequest.Interface
}
export type AutoInput = {
readonly session: SessionSchema.Info
readonly messages: readonly SessionMessage.Info[]
readonly resolved: SessionRunnerModel.Resolved
readonly prepare: SessionModelRequest.Interface["prepare"]
}
type RequiredInput = Pick<AutoInput, "messages" | "resolved">
@@ -86,6 +86,9 @@ export type ManualInput = {
readonly messages: readonly SessionMessage.Info[]
readonly inputID: SessionMessage.ID
readonly started?: boolean
/** Invoked after content planning, not when the caller captures the operation. */
readonly resolveModel: SessionContext.Interface["resolveModel"]
readonly prepare: SessionModelRequest.Interface["prepare"]
}
type Plan = {
@@ -96,6 +99,7 @@ type Plan = {
readonly recent: string
readonly inputID?: SessionMessage.ID
readonly started?: boolean
readonly prepare: SessionModelRequest.Interface["prepare"]
}
export type Outcome =
@@ -278,7 +282,7 @@ const make = (dependencies: Dependencies) => {
})
: Effect.void,
)
const prepared = yield* dependencies.modelRequests.prepare({
const prepared = yield* plan.prepare({
scope: { session: plan.session, agentID: Agent.ID.make("compaction"), model: plan.resolved },
transcript: { system: [], messages: [Message.user(plan.prompt)] },
contextHooks: false,
@@ -348,6 +352,7 @@ const make = (dependencies: Dependencies) => {
return yield* execute({
session: input.session,
resolved: input.resolved,
prepare: input.prepare,
reason: "auto",
...content,
})
@@ -387,7 +392,7 @@ const make = (dependencies: Dependencies) => {
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
inputID: input.inputID,
})
const resolved = yield* dependencies.models.resolve(input.session).pipe(
const resolved = yield* input.resolveModel(input.session).pipe(
Effect.catch((cause) =>
failed({
sessionID: input.session.id,
@@ -401,6 +406,7 @@ const make = (dependencies: Dependencies) => {
return yield* execute({
session: input.session,
resolved,
prepare: input.prepare,
reason: "manual",
inputID: input.inputID,
started: input.started,
@@ -422,14 +428,12 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const bus = yield* Bus.Service
const llm = yield* LLMClient.Service
const models = yield* SessionRunnerModel.Service
const modelRequests = yield* SessionModelRequest.Service
return make({ bus, llm, models, modelRequests })
return make({ bus, llm })
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Bus.node, llmClient, SessionRunnerModel.node, SessionModelRequest.node],
deps: [Bus.node, llmClient],
})
+198 -16
View File
@@ -1,7 +1,8 @@
export * as SessionContext from "./context.js"
import { Context, Effect, Layer } from "effect"
import { Context, Effect, Layer, Schema } from "effect"
import { Agent } from "../agent.js"
import { Catalog } from "../catalog.js"
import { CodeModeInstructions } from "../codemode/instructions.js"
import { Database } from "../database/database.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
@@ -11,14 +12,23 @@ import { InstructionBuiltIns } from "../instructions/builtins.js"
import { Location } from "../location.js"
import { McpInstructions } from "../mcp/instructions.js"
import { McpTool } from "../tool/mcp.js"
import { Model } from "../model.js"
import { PluginSupervisor } from "../plugin/supervisor.js"
import { ReferenceInstructions } from "../reference/instructions.js"
import { SkillInstructions } from "../skill/instructions.js"
import { Tool } from "../tool.js"
import { Permission } from "../permission.js"
import { Permissions } from "../permissions.js"
import { Image } from "../image.js"
import { PluginHooks } from "../plugin/hooks.js"
import { Source } from "../source.js"
import type { SessionCapabilities } from "./capabilities.js"
import { SessionSystemPrompt } from "./system-prompt.js"
import { AgentNotFoundError } from "./error.js"
import { SessionHistory } from "./history.js"
import { InstructionEntry } from "./instruction-entry.js"
import { SessionMessage } from "./message.js"
import { SessionModelRequest } from "./model-request.js"
import { SessionRunnerModel } from "./runner/model.js"
import { SessionSchema } from "./schema.js"
import { SessionStore } from "./store.js"
@@ -28,6 +38,8 @@ export interface Selection {
readonly agent: Agent.Selection & { readonly info: Agent.Info }
readonly instructions: Instructions.List
readonly tools: Tool.Snapshot
/** `baseTranscript` uses its default prefix for undefined; "" omits it when system text lives in the instruction epoch. */
readonly system?: string
}
export interface Loaded {
@@ -37,19 +49,33 @@ export interface Loaded {
readonly initial: string
readonly messages: ReadonlyArray<SessionMessage.Info>
readonly tools: Tool.Snapshot
readonly system?: Selection["system"]
}
/**
* Resolves model-request state in two phases: `select` fixes the Session,
* agent, instruction sources, and tool snapshot; `load` adds the model and
* active history for that selection. This module does not build or execute the
* model request.
* active history for that selection. Auxiliary operations resolve only the
* capabilities they need; request preparation stays separate from selection.
*/
export interface Interface {
/** Selects the Session, agent, instructions, and tools used by subsequent work. */
readonly select: (sessionID: SessionSchema.ID) => Effect.Effect<Selection, AgentNotFoundError>
/** Resolves the model and active history for that selection. */
readonly load: (selection: Selection) => Effect.Effect<Loaded, SessionRunnerModel.Error>
readonly resolveModel: (
session: SessionSchema.Info,
) => Effect.Effect<SessionRunnerModel.Resolved, SessionRunnerModel.Error>
/** Selects auxiliary title capabilities without instruction or tool preflight. */
readonly selectTitle: (session: SessionSchema.Info) => Effect.Effect<
| {
readonly agent: Agent.Info
readonly primary: SessionRunnerModel.Resolved | undefined
readonly selected: SessionRunnerModel.Resolved
}
| undefined
>
readonly prepare: SessionModelRequest.Interface["prepare"]
}
/** Location-scoped model-context loader for durable Session Steps. */
@@ -60,6 +86,7 @@ const layer = Layer.effect(
Effect.gen(function* () {
const agents = yield* Agent.Service
const builtins = yield* InstructionBuiltIns.Service
const catalog = yield* Catalog.Service
const db = (yield* Database.Service).db
const discovery = yield* InstructionDiscovery.Service
const entries = yield* InstructionEntry.Service
@@ -67,12 +94,41 @@ 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 plugins = yield* PluginSupervisor.Service
const referenceInstructions = yield* ReferenceInstructions.Service
const skillInstructions = yield* SkillInstructions.Service
const store = yield* SessionStore.Service
const registry = yield* Tool.Service
const resolveModel = (session: SessionSchema.Info) => models.resolve(session, catalog.model.available)
const selectTitle = Effect.fn("SessionContext.selectTitle")(function* (session: SessionSchema.Info) {
const agent = yield* agents.get(Agent.ID.make("title"))
if (!agent) return
const primary = yield* resolveModel(session).pipe(Effect.orElseSucceed(() => undefined))
const info = yield* Effect.gen(function* () {
if (agent.model) return yield* catalog.model.get(agent.model.providerID, agent.model.id)
if (!primary) return
return yield* catalog.model.small(primary.ref.providerID)
})
const variant =
agent.model?.variant ?? MINIMAL_REASONING_VARIANTS.find((id) => info?.variants.some((item) => item.id === id))
const preferred =
info &&
(yield* resolveModel({
...session,
model: Model.Ref.make({
providerID: info.providerID,
id: info.id,
...(variant ? { variant } : {}),
}),
}).pipe(Effect.orElseSucceed(() => undefined)))
const selected = preferred ?? primary
if (!selected) return
return { agent, primary, selected }
})
const select = Effect.fn("SessionContext.select")(function* (sessionID: SessionSchema.ID) {
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
@@ -111,28 +167,153 @@ const layer = Layer.effect(
}
})
const load = Effect.fn("SessionContext.load")(function* (selection: Selection) {
const model = yield* models.resolve(selection.session)
const history = yield* SessionHistory.entriesForRunner(db, selection.session.id, selection.instructions)
return {
session: selection.session,
agent: selection.agent,
model,
initial: history.initial,
messages: history.entries.map((entry) => entry.message),
tools: selection.tools,
}
return Service.of({
select,
load: load(db, resolveModel),
resolveModel,
selectTitle,
prepare: modelRequests.prepare,
})
return Service.of({ select, load })
}),
)
/** The values path shares instruction persistence and history assembly with discovery. */
export const values = (input: SessionCapabilities.OpenInput) =>
Layer.effect(
Service,
Effect.gen(function* () {
const db = (yield* Database.Service).db
const store = yield* SessionStore.Service
const entries = yield* InstructionEntry.Service
const requests = yield* SessionModelRequest.Service
const hooks = yield* PluginHooks.Service
const image = yield* Image.Service
const tools = Source.from(input.tools ?? [])
const instructions = Source.from(input.instructions ?? [])
const limits = Source.from(input.limits ?? {})
const permissions = input.permissions ?? Permissions.allowAll
const model = Source.from(input.model)
const resolveModel: Interface["resolveModel"] = (session) => model.get(session)
let cached:
| {
readonly tools: ReadonlyArray<Tool.Info>
readonly rules: Permission.Ruleset
readonly snapshot: Tool.Snapshot
}
| undefined
const select: Interface["select"] = Effect.fn("SessionContext.selectValues")(function* (sessionID) {
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
const selected = yield* Effect.all(
{
tools: tools.get(session),
rules: permissions.visibility.get(session),
limits: limits.get(session),
entries: entries.load(sessionID),
},
{ concurrency: "unbounded" },
)
if (cached?.tools !== selected.tools || cached.rules !== selected.rules)
cached = {
tools: selected.tools,
rules: selected.rules,
snapshot: yield* Tool.snapshot(selected.tools, selected.rules).pipe(
Effect.provideService(PluginHooks.Service, hooks),
Effect.provideService(Image.Service, image),
),
}
const snapshot = cached.snapshot
const id = session.agent ?? Agent.defaultID
return {
session,
agent: { id, info: { ...Agent.Info.default(id), permissions: selected.rules, steps: selected.limits.steps } },
// System text participates in the epoch instead of changing the privileged prefix.
system: "",
tools: snapshot,
instructions: Instructions.combine([
Instructions.make({
key: Instructions.Key.make("session/system"),
codec: Schema.String,
read:
input.system === undefined
? Effect.succeed(SessionSystemPrompt.make(snapshot.definitions.map((tool) => tool.name)))
: Source.from(input.system)
.get(session)
.pipe(Effect.map((value) => (value === "" ? Instructions.removed : value))),
render: {
initial: (value) => value,
changed: (_previous, value) =>
`The system instructions changed and supersede the previous value:\n${value}`,
removed: () => "The previous system instructions no longer apply.",
},
}),
CodeModeInstructions.make(snapshot.codeModeCatalog),
Instructions.make({
key: Instructions.Key.make("session/instructions"),
codec: Schema.Array(Schema.String),
read: instructions
.get(session)
.pipe(
Effect.map((value) =>
Array.isArray(value) && !value.some((part) => part.length > 0) ? Instructions.removed : value,
),
),
render: {
initial: (value) => value.join("\n\n"),
changed: (_previous, value) =>
`The session instructions changed and supersede the previous value:\n${value.join("\n\n")}`,
removed: () => "The previous session instructions no longer apply.",
},
}),
Instructions.make({
key: Instructions.Key.make("session/permissions"),
codec: Schema.toCodecJson(Permission.Ruleset),
read: Effect.succeed(selected.rules.length > 0 ? selected.rules : Instructions.removed),
render: {
initial: (value) => `Permission rules:\n${JSON.stringify(value)}`,
changed: (_previous, value) => `Permission rules changed:\n${JSON.stringify(value)}`,
removed: () => "The previous permission rules no longer apply.",
},
}),
selected.entries,
]),
}
})
return Service.of({
select,
load: load(db, resolveModel),
resolveModel,
selectTitle: () => Effect.undefined,
prepare: requests.prepare,
})
}),
)
function load(db: Database.Interface["db"], resolveModel: Interface["resolveModel"]): Interface["load"] {
return Effect.fn("SessionContext.load")(function* (selection: Selection) {
const model = yield* resolveModel(selection.session)
const history = yield* SessionHistory.entriesForRunner(db, selection.session.id, selection.instructions)
return {
session: selection.session,
agent: selection.agent,
model,
initial: history.initial,
messages: history.entries.map((entry) => entry.message),
tools: selection.tools,
...(selection.system === undefined ? {} : { system: selection.system }),
}
})
}
/** Variant IDs that minimize reasoning output, in preference order. */
const MINIMAL_REASONING_VARIANTS = ["none", "minimal", "low"].map((id) => Model.VariantID.make(id))
export const node = makeLocationNode({
service: Service,
layer,
deps: [
Agent.node,
Catalog.node,
Database.node,
InstructionBuiltIns.node,
InstructionDiscovery.node,
@@ -143,6 +324,7 @@ export const node = makeLocationNode({
PluginSupervisor.node,
ReferenceInstructions.node,
SessionRunnerModel.node,
SessionModelRequest.node,
SessionStore.node,
SkillInstructions.node,
Tool.node,
+23 -9
View File
@@ -14,6 +14,7 @@ import { SessionStore } from "./store.js"
import { toSessionError } from "./to-session-error.js"
import { UserInterruptedError } from "./error.js"
import { SessionInbox } from "./inbox.js"
import { SessionResolve } from "./resolve.js"
export interface Interface {
/** Snapshots active execution owned by this process. */
@@ -52,6 +53,7 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const store = yield* SessionStore.Service
const locations = yield* LocationServiceMap.Service
const resolve = yield* SessionResolve.Service
const bus = yield* Bus.Service
const jobs = yield* Job.Service
const db = (yield* Database.Service).db
@@ -86,10 +88,14 @@ export const layer = Layer.effect(
return Effect.gen(function* () {
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
const result = yield* SessionRunner.Service.use((runner) =>
runner.drain({ sessionID, force, continuation, promotable }),
const pinned = resolve.pinned(sessionID)
const result = yield* (
pinned
? pinned.drain({ sessionID, force, continuation, promotable })
: SessionRunner.Service.use((runner) => runner.drain({ sessionID, force, continuation, promotable })).pipe(
Effect.provide(locations.get(session.location)),
)
).pipe(
Effect.provide(locations.get(session.location)),
Effect.tapCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.void
@@ -101,11 +107,14 @@ export const layer = Layer.effect(
})
}
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError, InterruptReason>({
started: (sessionID) =>
reportLifecycle(
eligible: (sessionID) => resolve.status(sessionID) !== "owned-detached",
started: (sessionID) => {
resolve.pin(sessionID)
return reportLifecycle(
sessionID,
bus.publish(SessionEvent.Execution.Started, { sessionID }, claimOnCommit(sessionID)),
),
)
},
drain: (sessionID, force, promotable) => drain(sessionID, force, undefined, promotable),
// One terminal observation per busy period, covering every coalesced drain.
settled: (sessionID, exit, reason) =>
@@ -137,7 +146,7 @@ export const layer = Layer.effect(
releaseOnCommit(sessionID),
)
}),
),
).pipe(Effect.ensuring(resolve.settle(sessionID))),
})
return Service.of({
@@ -160,7 +169,12 @@ export const layer = Layer.effect(
yield* coordinator.wake(sessionID, "steer")
return interrupted
}),
resume: coordinator.run,
resume: (sessionID) =>
Effect.gen(function* () {
// TODO: typed unavailable-operation errors belong to the capability-gated operations phase.
if (resolve.status(sessionID) === "owned-detached") return yield* SessionResolve.unavailable(sessionID)
yield* coordinator.run(sessionID)
}),
wake: coordinator.wake,
awaitIdle: coordinator.awaitIdle,
})
@@ -170,7 +184,7 @@ export const layer = Layer.effect(
export const node = makeGlobalNode({
service: Service,
layer,
deps: [SessionStore.node, LocationServiceMap.node, Bus.node, Database.node, Job.node],
deps: [SessionStore.node, LocationServiceMap.node, Bus.node, Database.node, Job.node, SessionResolve.node],
})
/** Low-level compatibility layer for callers that only need durable Session recording. */
+28 -9
View File
@@ -7,6 +7,7 @@ import { Job } from "../../job.js"
import { Session } from "../../session.js"
import { SessionEvent } from "../event.js"
import { SessionExecution } from "../execution.js"
import { SessionResolve } from "../resolve.js"
import { SessionSchema } from "../schema.js"
import { SessionStore } from "../store.js"
@@ -37,6 +38,8 @@ export interface Interface {
* shutdown (which preserves the claim on purpose). The claim is never
* cleared here: only a terminal event releases it, so a death anywhere in
* the resume path leaves the same orphaned claim for the next boot.
* Capability-owned Sessions stay pending even when reopened; they require
* an explicit prompt or resume instead of automatic recovery.
*/
readonly resumeSuspendedSessions: Effect.Effect<void>
}
@@ -66,6 +69,7 @@ export const layer = (options?: Options) =>
Effect.gen(function* () {
const store = yield* SessionStore.Service
const execution = yield* SessionExecution.Service
const resolve = yield* SessionResolve.Service
const bus = yield* Bus.Service
const jobs = yield* Job.Service
const sessions = yield* Session.Service
@@ -73,6 +77,8 @@ export const layer = (options?: Options) =>
const maxAttempts = options?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS
const prepareResume = Effect.fnUntraced(function* (sessionID: SessionSchema.ID) {
// Reopening capabilities does not opt a Session into automatic recovery.
if (resolve.status(sessionID) !== "unowned") return undefined
// Durable before the resume runs, so a crash inside the resumed turn is
// counted by the next sweep and the budget cannot be dodged.
const attempts = yield* store.countResume(sessionID)
@@ -95,6 +101,14 @@ export const layer = (options?: Options) =>
return true
})
const eligibleJob = (recovery: Job.Recovery) => {
if (recovery.kind === "shell") return resolve.status(recovery.sessionID) === "unowned"
return (
resolve.status(recovery.parentSessionID) === "unowned" &&
resolve.status(recovery.childSessionID) === "unowned"
)
}
const recoverShell = Effect.fnUntraced(function* (
background: Job.Background,
recovery: Extract<Job.Recovery, { kind: "shell" }>,
@@ -143,6 +157,7 @@ export const layer = (options?: Options) =>
const notify = Effect.fnUntraced(function* (result: Pick<Job.Background, "status" | "output" | "error">) {
if (result.status === "running") return
if (!eligibleJob(recovery)) return
const text =
result.status === "completed"
? (result.output ?? "Subagent completed without a text response.")
@@ -172,7 +187,9 @@ export const layer = (options?: Options) =>
return
}
if ((yield* execution.active).has(recovery.childSessionID)) return
if (!(yield* prepareResume(recovery.childSessionID))) {
const prepared = yield* prepareResume(recovery.childSessionID)
if (prepared === undefined) return
if (!prepared) {
yield* notify({ status: "error", error: RESUME_EXHAUSTED.message })
return
}
@@ -214,18 +231,20 @@ export const layer = (options?: Options) =>
// Early notices wait for root recovery's accounting, including roots that exhaust their budget.
const suspended = new Set((yield* store.listSuspended()).filter((sessionID) => !active.has(sessionID)))
const pending = yield* jobs.pendingBackground
yield* store.releaseChildClaims(
pending.flatMap((background) =>
yield* store.releaseChildClaims([
...(yield* resolve.ownedIDs),
...pending.flatMap((background) =>
background.status === "running" && background.recovery.kind === "subagent"
? [background.recovery.childSessionID]
: [],
),
)
])
yield* Effect.forEach(
pending,
Effect.fnUntraced(function* (background) {
if ((yield* jobs.get(background.id))?.status === "running") return
const recovery = background.recovery
if (!eligibleJob(recovery)) return
yield* recovery.kind === "shell"
? recoverShell(background, recovery)
: recoverSubagent(background, recovery, suspended)
@@ -237,10 +256,10 @@ export const layer = (options?: Options) =>
const resumed = yield* execution.active
yield* Effect.forEach(
(yield* store.listSuspended()).filter((sessionID) => !resumed.has(sessionID)),
(sessionID) =>
execution
.resume(sessionID)
.pipe(Effect.ignore, Effect.forkIn(scope), Effect.when(prepareResume(sessionID))),
Effect.fnUntraced(function* (sessionID) {
if (!(yield* prepareResume(sessionID))) return
yield* execution.resume(sessionID).pipe(Effect.ignore, Effect.forkIn(scope))
}),
{ concurrency: "unbounded", discard: true },
)
// Async observers consult this set at delivery; later completions wake parents normally.
@@ -253,5 +272,5 @@ export const layer = (options?: Options) =>
export const node = makeGlobalNode({
service: Service,
layer: layer(),
deps: [SessionStore.node, SessionExecution.node, Bus.node, Job.node, Session.node],
deps: [SessionStore.node, SessionExecution.node, SessionResolve.node, Bus.node, Job.node, Session.node],
})
+3 -6
View File
@@ -9,7 +9,6 @@ import { SessionContext } from "./context.js"
import { SessionGenerate } from "./generate.js"
import { SessionHistory } from "./history.js"
import { SessionModelRequest } from "./model-request.js"
import { SessionRunnerModel } from "./runner/model.js"
export const layer = Layer.effect(
SessionGenerate.Service,
@@ -17,13 +16,11 @@ export const layer = Layer.effect(
const context = yield* SessionContext.Service
const database = yield* Database.Service
const llm = yield* LLMClient.Service
const models = yield* SessionRunnerModel.Service
const modelRequests = yield* SessionModelRequest.Service
return SessionGenerate.Service.of({
generate: Effect.fn("SessionGenerate.generate")(function* (input) {
const selection = yield* context.select(input.sessionID)
const model = yield* models.resolve(selection.session)
const model = yield* context.resolveModel(selection.session)
const history = yield* SessionHistory.preview(database.db, selection.session.id, selection.instructions)
const transcript = SessionModelRequest.baseTranscript({
agent: selection.agent.info,
@@ -32,7 +29,7 @@ export const layer = Layer.effect(
initial: history.initial,
messages: history.messages,
})
const prepared = yield* modelRequests.prepare({
const prepared = yield* context.prepare({
scope: { session: selection.session, agentID: selection.agent.id, model, tools: selection.tools },
transcript: {
system: transcript.system,
@@ -59,5 +56,5 @@ export const layer = Layer.effect(
export const node = makeLocationNode({
service: SessionGenerate.Service,
layer,
deps: [SessionContext.node, Database.node, SessionModelRequest.node, SessionRunnerModel.node, llmClient],
deps: [SessionContext.node, Database.node, llmClient],
})
@@ -103,7 +103,7 @@ const source = (entry: Info & { readonly removed: boolean }) =>
},
})
const layer = Layer.effect(
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const { db } = yield* Database.Service
+5 -3
View File
@@ -84,14 +84,16 @@ export const baseTranscript = (input: {
readonly tools: Tool.Snapshot
readonly initial: string
readonly messages: ReadonlyArray<SessionMessage.Info>
readonly system?: string
}) => {
const providerMetadataKey = input.model.model.route.providerMetadataKey ?? input.model.model.provider
return {
providerMetadataKey,
system: [
input.agent.system
? input.agent.system
: SessionSystemPrompt.make(input.tools.definitions.map((tool) => tool.name)),
input.system ??
(input.agent.system
? input.agent.system
: SessionSystemPrompt.make(input.tools.definitions.map((tool) => tool.name))),
input.initial,
]
.filter((part) => part.length > 0)
+291
View File
@@ -0,0 +1,291 @@
export * as SessionResolve from "./resolve.js"
import type { LLMClientService } from "@opencode-ai/ai"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Context, Deferred, Effect, Exit, Layer, Scope } from "effect"
import { Bus } from "../bus.js"
import { App } from "../app.js"
import { Database } from "../database/database.js"
import { llmClient, webSocketConstructor } from "../effect/app-node-platform.js"
import { KV } from "../kv.js"
import type { SessionCapabilities } from "./capabilities.js"
import type { SessionRunner } from "./runner/index.js"
import { SessionSchema } from "./schema.js"
import { SessionStore } from "./store.js"
import { Socket } from "effect/unstable/socket"
import type { Image } from "../image.js"
import type { SessionContext } from "./context.js"
import type { SessionModelTransport } from "./model-transport.js"
const prefix = "session.capabilities/"
/**
* Live operations, never an attempt snapshot. Title, request hooks, compaction,
* media/skills, snapshots, output, and transport customization are deferred;
* open supplies their internal defaults without directory discovery.
*/
interface Capabilities extends SessionContext.Interface {
readonly image: Image.Interface
readonly transport: SessionModelTransport.Interface
}
type Status = "attached" | "owned-detached" | "unowned"
type Resolved =
| { readonly status: "attached"; readonly capabilities: Capabilities }
| { readonly status: "owned-detached" | "unowned" }
type Opened = {
readonly capabilities: Capabilities
readonly runner: SessionRunner.Interface
readonly scope: Scope.Closeable
readonly onRetire: () => Effect.Effect<void>
readonly done: Deferred.Deferred<void>
current: boolean
users: number
}
export interface Interface {
readonly own: <A extends { readonly id: SessionSchema.ID }, R>(
record: Effect.Effect<A, never, R>,
) => Effect.Effect<A, never, R>
readonly status: (id: SessionSchema.ID) => Status
readonly ownedIDs: Effect.Effect<ReadonlyArray<SessionSchema.ID>>
readonly attach: (
id: SessionSchema.ID,
input: SessionCapabilities.OpenInput,
) => Effect.Effect<() => Effect.Effect<void>>
readonly resolve: (session: SessionSchema.Info) => Effect.Effect<Resolved, never, Scope.Scope>
/** Called synchronously when the coordinator installs a busy period, before its first fiber yield. */
readonly pin: (id: SessionSchema.ID) => void
readonly pinned: (id: SessionSchema.ID) => SessionRunner.Interface | undefined
readonly settle: (id: SessionSchema.ID) => Effect.Effect<void>
readonly remove: (id: SessionSchema.ID) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionResolve") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const kv = yield* KV.Service
const db = (yield* Database.Service).db
const owned = new Set(
(yield* kv.scanAll(prefix))
.filter((entry) => entry.value === true)
.map((entry) => SessionSchema.ID.make(entry.key.slice(prefix.length))),
)
const globals = yield* Effect.context<
| Database.Service
| Bus.Service
| SessionStore.Service
| LLMClientService
| FSUtil.Service
| Global.Service
| Socket.WebSocketConstructor
>()
const current = new Map<SessionSchema.ID, Opened>()
const pinned = new Map<SessionSchema.ID, Opened>()
const opened = new Map<Deferred.Deferred<void>, Opened>()
// LayerMap invalidation cannot choose synchronously at coordinator start or
// run a host hook after all generation-specific users settle. Keep explicit leases.
const retire = (value: Opened) =>
Effect.suspend(() => {
if (value.current || value.users > 0 || !opened.delete(value.done)) return Effect.void
return Scope.close(value.scope, Exit.void).pipe(
Effect.andThen(value.onRetire()),
Effect.onExit((exit) => Deferred.done(value.done, exit)),
)
})
const release = (value: Opened) =>
Effect.sync(() => {
value.users--
}).pipe(Effect.andThen(retire(value)))
yield* Effect.addFinalizer(() =>
Effect.sync(() => current.clear()).pipe(
Effect.andThen(
Effect.forEach(
opened.values(),
(value) => {
value.current = false
return retire(value)
},
{ discard: true },
),
),
),
)
return Service.of({
// Ownership is transitionally one-way: retirement never hands a Session back to discovery.
own: (record) =>
Effect.uninterruptible(
Effect.gen(function* () {
const session = yield* db
.transaction(() =>
Effect.gen(function* () {
const session = yield* record
if (!owned.has(session.id)) yield* kv.set(prefix + session.id, true)
return session
}),
)
.pipe(Effect.orDie)
// Publish the memory index only after the durable transaction commits.
owned.add(session.id)
return session
}),
),
status: (id) => (current.has(id) ? "attached" : owned.has(id) ? "owned-detached" : "unowned"),
ownedIDs: Effect.sync(() => Array.from(owned)),
attach: Effect.fn("SessionResolve.attach")(function* (id, input) {
const [
{ Image },
{ PluginHooks },
{ PluginSupervisor },
{ Snapshot },
{ ToolOutput },
{ SessionCompaction },
{ SessionContext },
{ InstructionEntry },
{ SessionModelRequest },
{ SessionModelTransport },
{ SessionRunner },
{ SessionRunnerLLM },
{ SessionTitle },
] = yield* Effect.promise(() =>
Promise.all([
import("../image.js"),
import("../plugin/hooks.js"),
import("../plugin/supervisor-service.js"),
import("../snapshot.js"),
import("../tool-output.js"),
import("./compaction.js"),
import("./context.js"),
import("./instruction-entry.js"),
import("./model-request.js"),
import("./model-transport.js"),
import("./runner/index.js"),
import("./runner/llm.js"),
import("./title.js"),
]),
)
const scope = yield* Scope.make()
const base = Layer.mergeAll(
PluginHooks.layer,
Image.layer,
InstructionEntry.layer,
SessionModelTransport.layer,
ToolOutput.layer,
Snapshot.noopLayer,
SessionCompaction.layer,
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }),
).pipe(Layer.provide(Layer.succeedContext(globals)))
const requests = SessionModelRequest.layer.pipe(Layer.provideMerge(base))
const capabilities = SessionContext.values(input).pipe(Layer.provideMerge(requests))
const runner = SessionRunnerLLM.layer.pipe(
Layer.provideMerge(SessionTitle.layer.pipe(Layer.provideMerge(capabilities))),
Layer.provide(Layer.succeedContext(globals)),
)
// Each open builds fresh local state over the SAME captured durable/global services.
const services = yield* Layer.buildWithScope(Layer.fresh(runner), scope).pipe(
Effect.onError(() => Scope.close(scope, Exit.void)),
)
const value: Opened = {
capabilities: {
...Context.get(services, SessionContext.Service),
image: Context.get(services, Image.Service),
transport: Context.get(services, SessionModelTransport.Service),
},
runner: Context.get(services, SessionRunner.Service),
scope,
onRetire: input.retire ?? (() => Effect.void),
done: Deferred.makeUnsafe<void>(),
current: true,
users: 0,
}
const previous = current.get(id)
current.set(id, value)
opened.set(value.done, value)
if (previous) {
previous.current = false
yield* retire(previous)
}
// Closed handles retain only completion, not retired capability functions or layers.
const done = value.done
return () =>
Effect.gen(function* () {
yield* Effect.uninterruptible(
Effect.gen(function* () {
const value = opened.get(done)
if (!value) return
if (current.get(id) === value) current.delete(id)
value.current = false
yield* retire(value)
}),
)
yield* Deferred.await(done)
})
}),
resolve: (session) =>
Effect.gen(function* () {
const value = current.get(session.id)
if (!value) return { status: owned.has(session.id) ? ("owned-detached" as const) : ("unowned" as const) }
yield* Effect.acquireRelease(
Effect.sync(() => {
value.users++
}),
() => release(value),
)
return { status: "attached" as const, capabilities: value.capabilities }
}),
pin: (id) => {
const value = current.get(id)
if (!value) return
value.users++
pinned.set(id, value)
},
pinned: (id) => pinned.get(id)?.runner,
settle: (id) =>
Effect.suspend(() => {
const value = pinned.get(id)
if (!value) return Effect.void
pinned.delete(id)
return release(value)
}),
remove: (id) =>
Effect.gen(function* () {
const value = current.get(id)
current.delete(id)
if (value) {
value.current = false
yield* retire(value)
}
if (!owned.has(id)) return
yield* kv.remove(prefix + id)
owned.delete(id)
}),
})
}),
)
export const node = makeGlobalNode({
service: Service,
layer,
deps: [
KV.node,
Database.node,
Bus.node,
SessionStore.node,
llmClient,
FSUtil.node,
Global.node,
webSocketConstructor,
// Request preparation reads this Reference from the captured globals, not its fallback metadata.
App.node,
],
})
/** TODO: replace this defect with the typed error in the capability-gated operations phase. */
export const unavailable = (sessionID: SessionSchema.ID) =>
Effect.die(new Error(`Session must be reopened with capabilities: ${sessionID}`))
+5 -1
View File
@@ -52,6 +52,8 @@ type Execution<E, Reason> = {
*/
export const make = <Key, E, Reason = never>(options: {
readonly drain: (key: Key, force: boolean, scope: Promotable) => Effect.Effect<void, E>
/** Controls new busy periods, including late successors; existing execution may finish. */
readonly eligible?: (key: Key) => boolean
/** Runs once when a process-local busy period begins, before its first drain. */
readonly started?: (key: Key) => Effect.Effect<void>
/**
@@ -107,7 +109,7 @@ export const make = <Key, E, Reason = never>(options: {
// A doorbell that survives the execution loop (rung after the loop decided to end, or
// during failure or interruption cleanup) starts a fresh execution for the remaining work.
const settle = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>) => {
if (execution.pendingWake) start(key, false, execution.pendingWake)
if (execution.pendingWake && (options.eligible?.(key) ?? true)) start(key, false, execution.pendingWake)
else executions.delete(key)
Deferred.doneUnsafe(execution.done, exit)
}
@@ -121,11 +123,13 @@ export const make = <Key, E, Reason = never>(options: {
return Deferred.await(execution.done).pipe(Effect.ignoreCause, Effect.andThen(run(key)))
return Deferred.await(execution.done)
}
if (options.eligible?.(key) === false) return Effect.interrupt
return Deferred.await(start(key, true, "input").done)
})
const wake = (key: Key, scope: Promotable = "input") =>
Effect.sync(() => {
if (options.eligible?.(key) === false) return
const execution = executions.get(key)
if (execution !== undefined) {
// Coalesced wakes keep the widest scope: "input" subsumes "steer".
+11 -5
View File
@@ -30,13 +30,12 @@ import { MAX_STEPS_PROMPT } from "./max-steps.js"
const CONTINUE_AFTER_INCOMPLETE_STREAM =
"The previous response was interrupted. Continue from where you left off without repeating completed content."
const layer = Layer.effect(
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const store = yield* SessionStore.Service
const context = yield* SessionContext.Service
const modelRequests = yield* SessionModelRequest.Service
const modelTransport = yield* SessionModelTransport.Service
const db = (yield* Database.Service).db
const compaction = yield* SessionCompaction.Service
@@ -142,6 +141,8 @@ const layer = Layer.effect(
Effect.gen(function* () {
return yield* compaction.compactManual({
session,
resolveModel: context.resolveModel,
prepare: context.prepare,
messages: yield* store.context(sessionID),
inputID: pending.id,
started: true,
@@ -215,7 +216,12 @@ const layer = Layer.effect(
// Reuse boundary preparation once; retries refresh context without delivering more input.
const loaded = initial ?? (yield* prepareContext(sessionID).pipe(Effect.flatMap(context.load)))
initial = undefined
const compactionInput = { session: loaded.session, messages: loaded.messages, resolved: loaded.model }
const compactionInput = {
session: loaded.session,
messages: loaded.messages,
resolved: loaded.model,
prepare: context.prepare,
}
if (compaction.required(compactionInput)) {
const compacted = yield* compaction.compact(compactionInput)
if (compacted.status !== "completed") return yield* new StepFailedError({ error: compacted.error })
@@ -229,8 +235,9 @@ const layer = Layer.effect(
tools: loaded.tools,
initial: loaded.initial,
messages: loaded.messages,
system: loaded.system,
})
const prepared = yield* modelRequests.prepare({
const prepared = yield* context.prepare({
scope: { session: loaded.session, agentID: loaded.agent.id, model: loaded.model, tools: loaded.tools },
transcript: {
system: transcript.system,
@@ -319,7 +326,6 @@ export const node = makeLocationNode({
Bus.node,
llmClient,
SessionContext.node,
SessionModelRequest.node,
SessionModelTransport.node,
SessionStore.node,
SessionCompaction.node,

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