Compare commits

..
Author SHA1 Message Date
vimtor 418cbd4d4e fix(core): persist rotated org credentials 2026-09-01 11:56:00 +02:00
vimtor 50fa545458 chore: sync v2 CI fixes 2026-09-01 11:42:47 +02:00
Victor NavarroandAiden Cline aadc0c1b4b fix: stabilize cross-platform unit tests (#46569)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-09-01 11:38:55 +02:00
vimtor 4d8b040cfd test(core): avoid Bun URL error wording 2026-09-01 11:21:14 +02:00
vimtor e9e0e5a4d7 fix(core): honor device token organization 2026-09-01 11:12:30 +02:00
Victor Navarro 94caa36fd4 test(server): wait for plugin readiness (#46567) 2026-09-01 10:52:45 +02:00
Brendan Allan a6f75d483a fix(app): follow file tree order in review navigation (#46557) 2026-09-01 07:25:08 +00:00
Aiden Cline 000b42d204 refactor(core): nest code mode catalog (#46541) 2026-09-01 00:01:31 -05:00
Aiden Cline 02440f6715 feat(codemode): label item and value schema comments (#46542) 2026-08-31 23:54:58 -05:00
Brendan AllanandDavid Hill d46ed9e9db Session message style (#46538)
Co-authored-by: David Hill <iamdavidhill@gmail.com>
2026-09-01 04:49:16 +00:00
opencode-agent[bot]andBrendonovich 71779ae5de fix(app): scope pane visibility to tabs (#46508)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-01 12:21:42 +08:00
Aiden Cline 4a3e25beab feat(codemode): compact schema constraint comments (#46521) 2026-08-31 23:06:49 -05:00
opencode-agent[bot]andrekram1-node 23fde448ec test(cli): isolate Bun define cache (#46536)
Co-authored-by: rekram1-node <63023139+rekram1-node@users.noreply.github.com>
2026-08-31 23:01:02 -05:00
opencode-agent[bot] 1137861188 chore: update nix node_modules hashes 2026-09-01 04:00:15 +00:00
Dax Raad 815d4ab9b4 fix(tui): scope connect dialog to location 2026-08-31 23:42:35 -04:00
Dax Raad 5d73a5789f feat(plugin): support live package updates 2026-08-31 23:35:21 -04:00
Aiden Cline df05945042 chore(ci): align Bun with the release runtime (#46524) 2026-08-31 21:48:35 -05:00
Aiden Cline 6a99898ef7 feat(core): register tool namespaces (#46487) 2026-08-31 21:29:19 -05:00
a40a87276a fix(tui): pin diff highlights query (#46518)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
Co-authored-by: Andreas Holt <6665487+AndreasHolt@users.noreply.github.com>
2026-08-31 21:06:43 -05:00
Luke Parker a20cbc394e feat(session-ui): preview images in read tool results (#46513) 2026-09-01 01:44:39 +00:00
Aiden Cline 8fda87614f feat(codemode): document numeric string and array constraints (#46510) 2026-08-31 20:44:07 -05:00
Kit Langton dffd95ce7c fix(codemode): reject Object.assign cycles (#46076) 2026-08-31 20:38:02 -04:00
opencode-agent[bot]andDavid Hill b0402f5a34 fix(session-ui): reduce inline code height (#46500)
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
2026-09-01 08:26:46 +08:00
Kit Langton 54b00ec5fe test(tui): capture flushed Mini scrollback output (#46505) 2026-08-31 20:26:36 -04:00
Kit Langton 6dd1733bbf fix(core): preserve continuation across chained moves
Carry unfinished model work across consecutive Location handoffs without resetting the logical step allowance. Keep idle moves and queued prompt admission unchanged. Cover steered and queued second moves, preserved tool history, and durable event ordering.
2026-08-31 20:12:47 -04:00
opencode-agent[bot]andBrendonovich 663c2dc1ce fix(app): raise composer only in dark mode (#46503)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-01 08:12:09 +08:00
Kit Langton 01eda4c178 refactor(codemode): name only supported operations (#46082) 2026-08-31 20:00:13 -04:00
Kit Langton a6b49b3f74 fix(shell): preserve output from fast-exiting commands
Capture child stdout and stderr eagerly before lazy Effect readers attach. Preserve the bounded post-exit drain and process cleanup policies, with delayed-consumption and backpressure regressions.
2026-08-31 19:52:08 -04:00
opencode-agent[bot]andrekram1-node 5b2276666f test(tui): stop Windows image preview test crashes (#46479)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-31 18:31:47 -05:00
Kit Langton cc0cc59700 fix(ai): preserve done-only response messages (#46064) 2026-08-31 19:24:33 -04:00
Kit Langton 57a9decefe refactor(codemode): simplify input conflict detection (#46465) 2026-08-31 19:24:07 -04:00
Kit Langton c0220ddd8b refactor(codemode): reject unresolved intersections before rendering (#46468) 2026-08-31 19:23:52 -04:00
Kit Langton b31defc0a5 refactor(util): slice the final filename segment (#46466) 2026-08-31 19:23:35 -04:00
Kit Langton e7d42f83e6 refactor(core): slice model references at the first slash (#46467) 2026-08-31 19:23:21 -04:00
opencode-agent[bot]andBrendonovich db768c4886 refactor(app): share mobile drawer primitive (#46453)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-01 07:03:50 +08:00
Aiden Cline 9553187ba6 fix(core): allow patch file-to-directory replacements (#46476) 2026-08-31 16:24:04 -05:00
Aiden Cline d04257eeb4 feat(codemode): add inline namespace metadata (#46464) 2026-08-31 16:01:14 -05:00
opencode-agent[bot]andrekram1-node d68f425c17 feat(cli): port the upgrade command to v2 (#46183)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-31 16:00:22 -05:00
Aiden Cline 49dd2cea34 refactor(ai): clarify responses adapters (#46469) 2026-08-31 15:36:57 -05:00
Simon Klee fac875dba0 mini v2 v2 (#46410) 2026-08-31 22:03:27 +02:00
Kit Langton 566ca864a0 refactor(util): reuse private patch buffers (#46459) 2026-08-31 15:11:54 -04:00
Dax Raad 5df9cecf03 fix(tui): remove plugin current marker 2026-08-31 14:31:03 -04:00
Dax Raad a68fe8a97d fix(tui): toggle plugin on dialog submit 2026-08-31 14:28:19 -04:00
Dax Raad c17c104827 fix(tui): toggle internal plugin controls 2026-08-31 14:25:06 -04:00
Dax Raad 5d4cc4a804 feat(tui): hide internal plugins by default 2026-08-31 14:25:06 -04:00
Kit Langton 1f04baa684 test: migrate fixture layer replacements (#46458)
Update the Core compile options and Server replacement values to the current LayerNode API. Preserve test expectations, replacement targets, and layer lifetimes.
2026-08-31 14:16:40 -04:00
Kit Langton 3e9b009642 feat(core): add session-aware instance selection (#46442) 2026-08-31 13:46:33 -04:00
Kit Langton 36ac35a7c8 refactor(util): make layer graphs opaque and composable
Replace exposed layer graph assembly with opaque declarations, checked substitutions, and lifetime-aware compilation. Preserve deep replacement, ordered startup, and Effect-owned resource lifetimes; migrate callers and verify source and published package contracts.
2026-08-31 13:46:27 -04:00
Kit Langton 197d28e033 fix(tui): pin sidebar headings without scrollbar flashes (#46449)
Keep the title and workspace label above scrollable sidebar details. Disable the unused horizontal scrollbar and place the automatic vertical scrollbar in the reserved gutter so tab changes do not flash or shift the sidebar.
2026-08-31 13:45:55 -04:00
opencode-agent[bot]andkitlangton fcce2d7cc9 test(tui): await dialog text selection (#46143)
Co-authored-by: kitlangton <7587245+kitlangton@users.noreply.github.com>
2026-08-31 13:44:00 -04:00
Dax Raad ec0dcb3da9 docs: improve build documentation discovery 2026-08-31 12:52:59 -04:00
Kit Langton afd7492018 fix(tui): reduce cached transcript remount work (#46145)
Configure custom Markdown renderers before assigning content and share a reactive message-position index across assistant footers. Preserve completion ordering and historical footer metrics, with regression coverage for prepend, same-length refresh, and revert.
2026-08-31 12:15:39 -04:00
Kit Langton 9517ff1054 fix(core): preserve active session continuation when moving 2026-08-31 12:14:45 -04:00
Kit Langton 1ced747051 fix(ai): handle message-less Gemini errors (#46069) 2026-08-31 12:14:31 -04:00
opencode-agent[bot]andDavid 43819dc376 fix(app): restore maskable pwa icons (#46434)
Co-authored-by: David <1879069+iamdavidhill@users.noreply.github.com>
2026-09-01 00:11:07 +08:00
Kit Langton e15dd8ecd3 fix(ai): require Bedrock message stop for finish (#46065) 2026-08-31 12:03:22 -04:00
Dax 6a38cacc1d docs: improve plugin guide readability (#46342) 2026-08-31 11:48:04 -04:00
Kit Langton d609752891 refactor(codemode): avoid merging root definitions twice (#46081) 2026-08-31 11:40:17 -04:00
Brendan Allan 5894e46688 fix(app): improve touch controls and standalone PWA relaunch (#46391) 2026-08-31 23:35:40 +08:00
Kit Langton 327dc809c5 refactor(core): reuse formatter file extension (#46080) 2026-08-31 11:30:57 -04:00
Kit Langton e9f7331516 refactor(core): reuse Markdown chunk byte counts (#46079) 2026-08-31 11:30:49 -04:00
Kit Langton 8be3ce8b6c refactor(util): reuse BOM-stripped text (#46078) 2026-08-31 11:30:40 -04:00
Kit Langton 30721b8b5d fix(server): await providers before catalog reads (#46066) 2026-08-31 11:24:56 -04:00
433 changed files with 15971 additions and 8020 deletions
+2
View File
@@ -969,6 +969,7 @@
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"pacote": "21.5.1",
"resolve.exports": "catalog:",
},
"devDependencies": {
@@ -978,6 +979,7 @@
"@types/node": "catalog:",
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3",
"@types/pacote": "11.1.8",
"@typescript/native-preview": "catalog:",
},
},
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-JStMvgtXBA5GrhyBJ5FtdqD8LWkcaPA9NXef+c2xUzw=",
"aarch64-linux": "sha256-WQxF+yS0ZImW0KW620XnZUBsKAJDoJ1jLDOMBaXI2/E=",
"aarch64-darwin": "sha256-km7G6s45dfFdW3Z6lFrs4NohD+vmwN1vR8PmEL0WCto=",
"x86_64-darwin": "sha256-YFbkcHpuspgTp+B+th3IJ3cnu5C2gEUSiy3NDXpD8UA="
"x86_64-linux": "sha256-nV2bI91uUqHJugt1mERgYNCn/zqWJ21YXc5YozwQ+Ss=",
"aarch64-linux": "sha256-s/0PghIWeRHsMw9Re84sQ8qW5IZwyhXrhmYlUy2xkt4=",
"aarch64-darwin": "sha256-FAm7Bk3NPikHYmmJFwB+0V3saRZqlyxDHuM8gasN3zA=",
"x86_64-darwin": "sha256-kdYwYQhubvO9CtVsuHsGnIJ9/byWQ5FMFCLRoScQ614="
}
}
+2 -2
View File
@@ -5,10 +5,10 @@
"version": "0.0.0",
"private": true,
"type": "module",
"packageManager": "bun@1.3.14",
"packageManager": "bun@1.4.0",
"scripts": {
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
"dev:live": "OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" bun run dev --server \"$(opencode2 service status)\"",
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev \"$@\" --server \"$(opencode2 service status)\"' --",
"dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
+1 -108
View File
@@ -241,121 +241,14 @@ Constructing `stream()` or `generate()` does not record a request, invoke a resp
Each execution does. An exhausted queue without a fallback defects immediately rather than waiting for a
future reply.
Generation responses remain canonical event arrays or arbitrary `Stream<LLMEvent, AIError>` values. The client consumes
Responses remain canonical event arrays or arbitrary `Stream<LLMEvent, AIError>` values. The client consumes
supplied streams directly, preserving failure identity, finalizers, incomplete output, and post-finish tails;
it does not repair or truncate them.
For explicit compaction, script a `CompactionResponse` through `push`, `always`, or `serve`. The client returns that replacement window directly, including retained user messages and usage, with the same lazy request recording and gates. Generation and compaction reject fixtures for the wrong operation instead of converting between response shapes.
The published legacy `Service`, `layer`, `clientLayer`, and module-level controls remain available as adapters
over the same implementation, including the legacy live `requests` array. New tests should use `Test` and
`testLayer`.
## Provider compaction
Compaction is opt-in. The package supports automatic compaction in OpenAI/Azure Responses and Anthropic Messages (including Claude on Vertex and Bedrock Messages), and explicit compaction calls in OpenAI/Azure/xAI Responses. Model and deployment support still depends on the provider.
This is different from prompt caching, server-side history storage, or truncation. Compaction returns provider-owned context that must be replayed to continue the conversation.
### Automatic compaction
Inside an `Effect.gen`, enable OpenAI compaction with typed provider options:
```ts
import { LLM, LLMClient, LLMRequest, Message } from "@opencode-ai/ai"
import { OpenAI } from "@opencode-ai/ai/providers"
const request = LLM.request({
model: OpenAI.configure({ apiKey }).responses("gpt-5.3-codex"),
messages,
providerOptions: {
contextManagement: [{ type: "compaction", compactThreshold: 200_000 }],
},
})
const response = yield * LLMClient.generate(request)
const next = LLMRequest.update(request, {
messages: [...request.messages, response.message, Message.user("Continue")],
})
```
`store: false` remains the default. Keep the entire `response.message`, not just `response.text`. Compaction events become ordered `CompactionPart`s alongside text and reasoning. The conversation contains everything needed to continue; there is no separate replay object or hidden provider transcript.
A compaction part has `provider` and exactly one representation: `encrypted` for Responses, or `text` for Anthropic. Responses also preserves the optional checkpoint `id`. These fields survive message serialization without becoming visible assistant text. Sending a checkpoint to another provider or an incompatible API fails rather than silently losing context.
```ts
import { CompactionPart, ProviderID } from "@opencode-ai/ai"
CompactionPart.make({ provider: ProviderID.make("openai"), id: "cmp_123", encrypted: "..." })
CompactionPart.make({ provider: ProviderID.make("anthropic"), text: "Summary of the conversation..." })
```
For Anthropic, use:
```ts
providerOptions: {
contextManagement: {
edits: [{
type: "compact_20260112",
trigger: { type: "input_tokens", value: 150_000 },
pauseAfterCompaction: true,
instructions: "Summarize the task and decisions. Do not call tools while summarizing.",
}],
},
}
```
- The trigger is optional (provider default: 150,000 tokens), with a minimum of 50,000.
- Custom instructions replace Anthropic's default summarization instructions.
- The route adds `compact-2026-01-12` to existing beta headers, including when replaying a checkpoint without enabling new compactions.
- A pause is exposed as `response.finishReason.raw === "compaction"`. The caller explicitly issues the next request; the package never automatically resumes.
- Anthropic can return a compaction block with `content: null` when summarization fails. This becomes a compaction part with `text: null`, which is **not** a successful replacement for prior history. The package never prunes history automatically.
- `Usage` totals include all reported Anthropic `usage.iterations`, including compaction. `contextTokens` separately reports the final message iteration's inclusive input size, when available. A compaction-only pause does not report a post-compaction context size. Raw iteration usage remains in `providerMetadata`.
Bedrock's Converse API does not support this feature. Select the native Claude Messages route explicitly; the default `.model(...)` remains Converse:
```ts
import { AmazonBedrock } from "@opencode-ai/ai/providers"
const model = AmazonBedrock.configure({ region: "us-east-1", credentials }).messages("us.anthropic.claude-opus-4-6-v1")
```
The corresponding package entrypoint is `@opencode-ai/ai/providers/amazon-bedrock/messages`. It uses InvokeModelWithResponseStream, AWS event-stream framing, bearer or SigV4 auth, and `anthropic_beta` in the request body.
### Explicit compaction
`LLMClient.compact(request)` performs exactly one HTTP call to `/responses/compact`, using the selected route's endpoint, credentials, query, and HTTP middleware. It returns a `CompactionResponse` containing replacement `messages` and usage, not a normal generation response.
The selected model carries explicit-compaction capability through request construction and updates. Calls using unsupported routes fail type checking. When the model is selected dynamically, narrow the request with `LLMClient.canCompact(request)` before calling `LLMClient.compact`; a model or route switch does not inherit the old capability. Runtime validation still rejects unsupported calls from untyped consumers. Capability describes the route's API, not whether every model or custom deployment supports the operation.
```ts
const compacted = yield * LLMClient.compact(request)
const next = LLMRequest.update(request, {
messages: [...compacted.messages, Message.user("Continue")],
})
const response = yield * LLMClient.generate(next)
```
Replace the prior window with `compacted.messages`. Do not append it to the original transcript or extract only the encrypted item: the provider may retain additional messages in its output. Retained user and assistant messages remain ordinary messages with typed text, media, or reasoning parts, in their original order. Provider-specific message IDs, status, and phase use `providerMetadata`, not a raw output array hidden in an assistant message. Unsupported returned item types fail explicitly. Generation-only body overlays such as `stream` and `store` are not sent to the compact endpoint.
Supported compact controls such as service tier and prompt-cache settings preserve request defaults and HTTP-overlay precedence. Retained image and file detail settings survive serialization and replay.
The input must still fit the model's context window. Explicit compaction is not an overflow-recovery operation. xAI supports this explicit path, not the automatic OpenAI option. Unsupported routes, including Bedrock Mantle, do not inherit an explicit compact endpoint simply because they use a Responses protocol.
### Ownership and verification
The AI package transports options and typed conversation parts. It does not schedule compaction, persist Session checkpoints, select history, switch providers, or replace Core's existing local compaction policy. Native compaction is not enabled for OpenCode Sessions by this feature; Session integration must persist these parts before enabling it. The AI SDK bridge rejects native compaction parts rather than dropping them. Provider-executed tool APIs and persistence changes are a separate follow-up.
Tests cover serialized round trips, real local HTTP plus a tool loop, AWS binary frames and signing, provider errors, malformed blocks, and usage accounting. Live provider tests are gated by `RECORD=true` and the relevant API keys:
```sh
# Run from packages/ai. Only records the selected new cassette group.
RECORD=true RECORDED_PREFIX=openai-compaction bun test test/provider/compaction.recorded.test.ts
RECORD=true RECORDED_PREFIX=xai-compaction bun test test/provider/compaction.recorded.test.ts
RECORD=true RECORDED_PREFIX=anthropic-compaction bun test test/provider/compaction.recorded.test.ts
```
Provider references: [OpenAI](https://developers.openai.com/api/docs/guides/compaction), [Azure](https://learn.microsoft.com/en-us/azure/foundry/openai/how-to/responses#server-side-compaction), [Anthropic](https://platform.claude.com/docs/en/build-with-claude/compaction), [Bedrock](https://docs.aws.amazon.com/bedrock/latest/userguide/claude-messages-compaction.html), [xAI](https://docs.x.ai/developers/advanced-api-usage/context-compaction).
## Caching
Prompt caching is **on by default**. Every `LLMRequest` resolves to `cache: "auto"` unless the caller opts out with `cache: "none"`. Each protocol translates `CacheHint`s to its wire format (`cache_control` on Anthropic, `cachePoint` on Bedrock; OpenAI and Gemini do implicit caching server-side and don't need inline markers — auto is a no-op there).
-1
View File
@@ -40,7 +40,6 @@ const RESPECTS_INLINE_HINTS = new Set([
"anthropic-messages",
"google-vertex-messages",
"bedrock-converse",
"bedrock-messages",
"openrouter",
])
+27 -179
View File
@@ -6,12 +6,8 @@ import { Auth } from "../route/auth.js"
import { Endpoint } from "../route/endpoint.js"
import { Framing } from "../route/framing.js"
import { Protocol } from "../route/protocol.js"
import { Headers } from "effect/unstable/http"
import { HttpTransport } from "../route/transport/index.js"
import {
AIError,
HttpOptions,
LLMRequest,
LLMEvent,
mergeJsonRecords,
Usage,
@@ -19,6 +15,7 @@ import {
type FinishReasonDetails,
type FinishReason,
type JsonSchema,
type LLMRequest,
type MediaPart,
type ProviderMetadata,
type ToolCallPart,
@@ -64,7 +61,6 @@ export type ThinkingInput =
))
export interface OptionsInput {
readonly contextManagement?: ContextManagement
readonly [key: string]: unknown
readonly thinking?: ThinkingInput
readonly effort?: string
@@ -93,23 +89,6 @@ export interface OptionsInput {
export type ProviderOptionsInput = OptionsInput
export const ContextManagement = Schema.Struct({
edits: Schema.Array(
Schema.Struct({
type: Schema.Literal("compact_20260112"),
trigger: Schema.optional(
Schema.Struct({
type: Schema.Literal("input_tokens"),
value: Schema.Int.check(Schema.isGreaterThanOrEqualTo(50000)),
}),
),
pauseAfterCompaction: Schema.optional(Schema.Boolean),
instructions: Schema.optional(Schema.String),
}),
),
})
export type ContextManagement = typeof ContextManagement.Type
// =============================================================================
// Request Body Schema
// =============================================================================
@@ -257,12 +236,7 @@ const AnthropicUserBlock = Schema.Union([
AnthropicToolResultBlock,
])
type AnthropicUserBlock = Schema.Schema.Type<typeof AnthropicUserBlock>
const AnthropicCompactionBlock = Schema.Struct({
type: Schema.Literal("compaction"),
content: Schema.NullOr(Schema.String),
})
const AnthropicAssistantBlock = Schema.Union([
AnthropicCompactionBlock,
AnthropicTextBlock,
AnthropicThinkingBlock,
AnthropicRedactedThinkingBlock,
@@ -338,18 +312,6 @@ const AnthropicContainer = Schema.Union([
])
const AnthropicBodyFields = {
context_management: Schema.optional(
Schema.Struct({
edits: Schema.Array(
Schema.Struct({
type: Schema.Literal("compact_20260112"),
trigger: ContextManagement.fields.edits.value.fields.trigger,
pause_after_compaction: Schema.optional(Schema.Boolean),
instructions: Schema.optional(Schema.String),
}),
),
}),
),
model: Schema.String,
system: optionalArray(AnthropicTextBlock),
messages: Schema.Array(AnthropicMessage),
@@ -373,7 +335,7 @@ const AnthropicBodyFields = {
export const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields)
export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesBody>
const AnthropicIterationUsage = Schema.StructWithRest(
const AnthropicUsage = Schema.StructWithRest(
Schema.Struct({
input_tokens: optionalNull(Schema.Number),
output_tokens: Schema.optional(Schema.Number),
@@ -392,13 +354,6 @@ const AnthropicIterationUsage = Schema.StructWithRest(
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
const AnthropicUsage = Schema.StructWithRest(
Schema.Struct({
...AnthropicIterationUsage.schema.fields,
iterations: Schema.optional(Schema.Array(AnthropicIterationUsage)),
}),
[JsonObject],
)
type AnthropicUsage = Schema.Schema.Type<typeof AnthropicUsage>
const AnthropicStreamBlock = Schema.Struct({
@@ -422,7 +377,6 @@ type AnthropicStreamBlock = Schema.Schema.Type<typeof AnthropicStreamBlock>
const decodeAnthropicStreamBlock = Schema.decodeUnknownOption(AnthropicStreamBlock)
const AnthropicStreamDelta = Schema.Struct({
content: optionalNull(Schema.String),
type: Schema.optional(Schema.String),
text: Schema.optional(Schema.String),
thinking: Schema.optional(Schema.String),
@@ -452,8 +406,6 @@ const AnthropicEvent = Schema.Struct({
type AnthropicEvent = Schema.Schema.Type<typeof AnthropicEvent>
interface ParserState {
readonly provider: LLMRequest["model"]["provider"]
readonly compactions: Readonly<Record<number, string | null>>
readonly providerMetadataKey: string
readonly tools: ToolStream.State<number>
readonly reasoningSignatures: Readonly<Record<number, string>>
@@ -896,12 +848,6 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
if (message.role === "assistant") {
const content: AnthropicAssistantBlock[] = []
for (const part of message.content) {
if (part.type === "compaction") {
if (part.provider !== request.model.provider || part.text === undefined)
return yield* invalid("Compaction state must be replayed to its originating provider and API")
content.push({ type: "compaction", content: part.text })
continue
}
if (part.type === "text") {
if (part.text.trim().length === 0) continue
content.push({ type: "text", text: part.text, cache_control: cacheControl(breakpoints, part.cache) })
@@ -1057,9 +1003,6 @@ const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function*
})
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
const management = yield* ProviderShared.validateWith(
Schema.decodeUnknownEffect(Schema.UndefinedOr(ContextManagement)),
)(request.providerOptions?.contextManagement)
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
// Allocate the 4-breakpoint budget in invalidation order: tools → system →
@@ -1094,7 +1037,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
)
}
const options = yield* resolveOptions(request)
const body = {
return {
model: request.model.id,
system,
messages,
@@ -1115,18 +1058,6 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
metadata: options.metadata,
service_tier: options.service_tier,
}
if (!management) return body
return {
...body,
context_management: {
edits: management.edits.map((edit) => ({
type: edit.type,
trigger: edit.trigger,
pause_after_compaction: edit.pauseAfterCompaction,
instructions: edit.instructions,
})),
},
}
})
// =============================================================================
@@ -1148,31 +1079,18 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
// expose that subset through `output_tokens_details.thinking_tokens`.
const mapUsage = (usage: AnthropicUsage | undefined, providerMetadataKey: string): Usage | undefined => {
if (!usage) return undefined
const iterations = usage.iterations?.length ? usage.iterations : [usage]
const last = usage.iterations?.at(-1)
const nonCached = ProviderShared.sumTokens(...iterations.map((item) => item.input_tokens ?? undefined))
const cacheRead = ProviderShared.sumTokens(...iterations.map((item) => item.cache_read_input_tokens ?? undefined))
const cacheWrite = ProviderShared.sumTokens(
...iterations.map((item) => item.cache_creation_input_tokens ?? undefined),
)
const nonCached = usage.input_tokens ?? undefined
const cacheRead = usage.cache_read_input_tokens ?? undefined
const cacheWrite = usage.cache_creation_input_tokens ?? undefined
const inputTokens = ProviderShared.sumTokens(nonCached, cacheRead, cacheWrite)
const outputTokens = ProviderShared.sumTokens(...iterations.map((item) => item.output_tokens))
return new Usage({
inputTokens,
outputTokens,
contextTokens:
last?.type === "message"
? ProviderShared.sumTokens(
last.input_tokens ?? undefined,
last.cache_read_input_tokens ?? undefined,
last.cache_creation_input_tokens ?? undefined,
)
: undefined,
outputTokens: usage.output_tokens,
nonCachedInputTokens: nonCached,
cacheReadInputTokens: cacheRead,
cacheWriteInputTokens: cacheWrite,
reasoningTokens: ProviderShared.sumTokens(...iterations.map((item) => item.output_tokens_details?.thinking_tokens)),
totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined),
reasoningTokens: usage.output_tokens_details?.thinking_tokens,
totalTokens: ProviderShared.totalTokens(inputTokens, usage.output_tokens, undefined),
providerMetadata: { [providerMetadataKey]: usage },
})
}
@@ -1194,7 +1112,6 @@ const mergeUsage = (left: Usage | undefined, right: Usage | undefined, providerM
return new Usage({
inputTokens,
outputTokens,
contextTokens: right.contextTokens ?? left.contextTokens,
nonCachedInputTokens,
cacheReadInputTokens,
cacheWriteInputTokens,
@@ -1253,6 +1170,7 @@ const onContentBlockStart = (
event: AnthropicEvent & { readonly content_block: AnthropicStreamBlock },
): StepResult => {
const block = event.content_block
if (!block) return [state, NO_EVENTS]
if (block.type === "tool_use" || block.type === "server_tool_use") {
if (event.index === undefined || !block.id) return [state, NO_EVENTS]
@@ -1347,16 +1265,7 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
) {
const delta = event.delta
if (delta.type === "compaction_delta") {
if (event.index === undefined || !(event.index in state.compactions) || delta.content === undefined)
return yield* ProviderShared.eventError(ADAPTER, "Compaction delta is missing its block or content")
return [
{ ...state, compactions: { ...state.compactions, [event.index]: delta.content } },
NO_EVENTS,
] satisfies StepResult
}
if (delta.type === "text_delta" && delta.text) {
if (delta?.type === "text_delta" && delta.text) {
if (!state.lifecycle.text.has(`text-${event.index ?? 0}`)) return [state, NO_EVENTS] satisfies StepResult
const events: LLMEvent[] = []
return [
@@ -1365,7 +1274,7 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
] satisfies StepResult
}
if (delta.type === "thinking_delta" && delta.thinking) {
if (delta?.type === "thinking_delta" && delta.thinking) {
if (!state.lifecycle.reasoning.has(`reasoning-${event.index ?? 0}`)) return [state, NO_EVENTS] satisfies StepResult
const events: LLMEvent[] = []
return [
@@ -1377,7 +1286,7 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
] satisfies StepResult
}
if (delta.type === "signature_delta" && delta.signature) {
if (delta?.type === "signature_delta" && delta.signature) {
const index = event.index ?? 0
if (!state.lifecycle.reasoning.has(`reasoning-${index}`)) return [state, NO_EVENTS] satisfies StepResult
return [
@@ -1389,7 +1298,7 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
] satisfies StepResult
}
if (delta.type === "input_json_delta" && event.index !== undefined) {
if (delta?.type === "input_json_delta" && event.index !== undefined) {
if (!delta.partial_json) return [state, NO_EVENTS] satisfies StepResult
if (!state.tools[event.index]) return [state, NO_EVENTS] satisfies StepResult
const result = ToolStream.appendExisting(
@@ -1414,18 +1323,6 @@ const onContentBlockStop = Effect.fn("AnthropicMessages.onContentBlockStop")(fun
event: AnthropicEvent,
) {
if (event.index === undefined) return [state, NO_EVENTS] satisfies StepResult
if (event.index in state.compactions) {
const { [event.index]: content, ...compactions } = state.compactions
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
events.push(
LLMEvent.compaction({
provider: state.provider,
text: content,
}),
)
return [{ ...state, compactions, lifecycle }, events] satisfies StepResult
}
const result = yield* ToolStream.finish(ADAPTER, state.tools, event.index)
const events: LLMEvent[] = []
const resultEvents = result.events ?? []
@@ -1477,8 +1374,6 @@ const onMessageDelta = (
}
const onMessageStop = Effect.fn("AnthropicMessages.onMessageStop")(function* (state: ParserState) {
if (Object.keys(state.compactions).length)
return yield* ProviderShared.eventError(ADAPTER, "Response ended with an incomplete compaction block")
const result = yield* ToolStream.finishAll(ADAPTER, state.tools)
const events: LLMEvent[] = []
const lifecycle = result.events.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
@@ -1523,21 +1418,16 @@ const onError = (event: AnthropicEvent) => {
)
}
const STREAM_BLOCK_TYPES = new Set([
"compaction",
"text",
"thinking",
"redacted_thinking",
"tool_use",
"server_tool_use",
])
const STREAM_DELTA_TYPES = new Set([
"compaction_delta",
"text_delta",
"thinking_delta",
"signature_delta",
"input_json_delta",
])
const isKnownStreamBlockType = (type: string) =>
type === "text" ||
type === "thinking" ||
type === "redacted_thinking" ||
type === "tool_use" ||
type === "server_tool_use" ||
isServerToolResultType(type)
const isKnownStreamDeltaType = (type: string) =>
type === "text_delta" || type === "thinking_delta" || type === "signature_delta" || type === "input_json_delta"
const invalidStreamEvent = (event: AnthropicEvent) =>
Effect.fail(
@@ -1566,16 +1456,7 @@ const step = (state: ParserState, event: AnthropicEvent) => {
if (event.type === "content_block_start") {
if (!ProviderShared.isRecord(event.content_block) || typeof event.content_block.type !== "string")
return invalidStreamEvent(event)
if (event.content_block.type === "compaction") {
const decoded = Schema.decodeUnknownOption(AnthropicCompactionBlock)(event.content_block)
if (event.index === undefined || Option.isNone(decoded)) return invalidStreamEvent(event)
return Effect.succeed<StepResult>([
{ ...state, compactions: { ...state.compactions, [event.index]: decoded.value.content } },
NO_EVENTS,
])
}
if (!STREAM_BLOCK_TYPES.has(event.content_block.type) && !isServerToolResultType(event.content_block.type))
return Effect.succeed<StepResult>([state, NO_EVENTS])
if (!isKnownStreamBlockType(event.content_block.type)) return Effect.succeed<StepResult>([state, NO_EVENTS])
const decoded = decodeAnthropicStreamBlock(event.content_block)
if (Option.isNone(decoded)) return invalidStreamEvent(event)
const block = decoded.value
@@ -1589,7 +1470,7 @@ const step = (state: ParserState, event: AnthropicEvent) => {
}
if (event.type === "content_block_delta") {
if (!ProviderShared.isRecord(event.delta)) return invalidStreamEvent(event)
if (typeof event.delta.type === "string" && !STREAM_DELTA_TYPES.has(event.delta.type))
if (typeof event.delta.type === "string" && !isKnownStreamDeltaType(event.delta.type))
return Effect.succeed<StepResult>([state, NO_EVENTS])
const decoded = decodeAnthropicStreamDelta(event.delta)
if (Option.isNone(decoded)) return invalidStreamEvent(event)
@@ -1623,8 +1504,6 @@ export const protocol = Protocol.make({
stream: {
event: Protocol.jsonEvent(AnthropicEvent),
initial: (request) => ({
provider: request.model.provider,
compactions: {},
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
tools: ToolStream.empty<number>(),
reasoningSignatures: {},
@@ -1634,37 +1513,6 @@ export const protocol = Protocol.make({
},
})
export const transport = <Body extends Pick<AnthropicMessagesBody, "messages" | "context_management">>() => {
const http = HttpTransport.httpJson<Body, string>({ framing })
return {
...http,
prepare: (input: Parameters<typeof http.prepare>[0]) => {
if (
!input.body.context_management?.edits.length &&
!input.body.messages.some((message) => message.content.some((block) => block.type === "compaction"))
)
return http.prepare(input)
const headers = Headers.fromInput(input.request.http?.headers)
const betas = new Set(
(headers["anthropic-beta"] ?? "")
.split(",")
.map((item) => item.trim())
.filter(Boolean),
)
betas.add("compact-2026-01-12")
return http.prepare({
...input,
request: LLMRequest.update(input.request, {
http: new HttpOptions({
...input.request.http,
headers: { ...headers, "anthropic-beta": [...betas].join(",") },
}),
}),
})
},
}
}
export const route = Route.make({
id: ADAPTER,
provider: "anthropic",
@@ -1674,7 +1522,7 @@ export const route = Route.make({
baseURL: DEFAULT_BASE_URL,
}),
auth: Auth.none,
transport: transport<AnthropicMessagesBody>(),
framing,
headers: () => ({ "anthropic-version": "2023-06-01" }),
})
+16 -20
View File
@@ -510,9 +510,10 @@ interface ParserState {
readonly tools: ToolStream.State<number>
readonly finishedTools: ReadonlySet<number>
// Bedrock splits the finish into `messageStop` (carries `stopReason`) and
// `metadata` (carries usage). Hold the terminal event in state so `onHalt`
// can emit exactly one finish after both chunks have had a chance to arrive.
readonly pendingFinish: { readonly reason: FinishReasonDetails; readonly usage?: Usage } | undefined
// `metadata` (carries usage). Hold both in state so `onHalt` can emit exactly
// one finish after both chunks have had a chance to arrive.
readonly finishReason: FinishReasonDetails | undefined
readonly usage: Usage | undefined
readonly hasToolCalls: boolean
readonly lifecycle: Lifecycle.State
readonly reasoningSignatures: Readonly<Record<number, string>>
@@ -692,12 +693,9 @@ const step = (state: ParserState, event: BedrockEvent) =>
return [
{
...state,
pendingFinish: {
reason: {
normalized: mapFinishReason(event.messageStop.stopReason),
raw: event.messageStop.stopReason,
},
usage: state.pendingFinish?.usage,
finishReason: {
normalized: mapFinishReason(event.messageStop.stopReason),
raw: event.messageStop.stopReason,
},
},
[],
@@ -705,14 +703,11 @@ const step = (state: ParserState, event: BedrockEvent) =>
}
if (event.metadata) {
const usage = mapUsage(event.metadata.usage, state.providerMetadataKey) ?? state.pendingFinish?.usage
const usage = mapUsage(event.metadata.usage, state.providerMetadataKey) ?? state.usage
return [
{
...state,
pendingFinish: {
reason: state.pendingFinish?.reason ?? { normalized: "stop" },
usage,
},
usage,
},
[],
] as const
@@ -736,18 +731,18 @@ const step = (state: ParserState, event: BedrockEvent) =>
const framing = BedrockEventStream.framing(ADAPTER)
const onHalt = (state: ParserState): ReadonlyArray<LLMEvent> => {
if (!state.pendingFinish) return []
if (!state.finishReason) return []
const normalized = (() => {
if (state.pendingFinish.reason.normalized === "stop" && state.hasToolCalls) return "tool-calls"
return state.pendingFinish.reason.normalized
if (state.finishReason.normalized === "stop" && state.hasToolCalls) return "tool-calls"
return state.finishReason.normalized
})()
const events: LLMEvent[] = []
Lifecycle.finish(state.lifecycle, events, {
reason: {
...state.pendingFinish.reason,
...state.finishReason,
normalized,
},
usage: state.pendingFinish.usage,
usage: state.usage,
})
return events
}
@@ -771,7 +766,8 @@ export const protocol = Protocol.make({
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
tools: ToolStream.empty<number>(),
finishedTools: new Set<number>(),
pendingFinish: undefined,
finishReason: undefined,
usage: undefined,
hasToolCalls: false,
lifecycle: Lifecycle.initial(),
reasoningSignatures: {},
@@ -1,121 +0,0 @@
import { Effect, Encoding, Schema, Struct } from "effect"
import { Headers } from "effect/unstable/http"
import { AIError } from "../schema/index.js"
import { Route } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Protocol } from "../route/protocol.js"
import { classifyProviderFailure } from "../provider-error.js"
import { AnthropicMessages } from "./anthropic-messages.js"
import { BedrockEventStream } from "./bedrock-event-stream.js"
import { BedrockAuth } from "./utils/bedrock-auth.js"
import { JsonObject, ProviderShared } from "./shared.js"
const ID = "bedrock-messages"
const VERSION = "bedrock-2023-05-31"
const Body = Schema.Struct({
...Struct.omit(AnthropicMessages.AnthropicMessagesBody.fields, ["model", "stream"]),
anthropic_version: Schema.Literal(VERSION),
anthropic_beta: Schema.optional(Schema.Array(Schema.String)),
}).check(
Schema.makeFilter(
(body) =>
body.messages.flatMap((message) => message.content.map(mediaIssue)).find((issue) => issue !== undefined) ?? true,
),
)
const Event = Schema.Struct({
chunk: Schema.optional(Schema.Struct({ bytes: Schema.String })),
exception: Schema.optional(
Schema.Struct({
type: Schema.String,
details: Schema.StructWithRest(
Schema.Struct({ message: Schema.optional(Schema.String), originalMessage: Schema.optional(Schema.String) }),
[JsonObject],
),
}),
),
})
export const protocol = Protocol.make({
id: ID,
body: {
schema: Body,
from: Effect.fn("BedrockMessages.fromRequest")(function* (request) {
const body = yield* AnthropicMessages.protocol.body.from(request)
const headers = Headers.fromInput(request.http?.headers)
const betas = new Set(
(headers["anthropic-beta"] ?? "")
.split(",")
.map((value) => value.trim())
.filter(Boolean),
)
if (
body.context_management?.edits.length ||
body.messages.some((message) => message.content.some((block) => block.type === "compaction"))
)
betas.add("compact-2026-01-12")
return {
...Struct.omit(body, ["model", "stream"]),
anthropic_version: VERSION,
anthropic_beta: betas.size ? [...betas] : undefined,
} satisfies typeof Body.Type
}),
},
stream: {
event: Event,
initial: AnthropicMessages.protocol.stream.initial,
step: Effect.fn("BedrockMessages.step")(function* (state, event) {
if (event.exception)
return yield* new AIError({
reason: classifyProviderFailure({
message: event.exception.details.message ?? event.exception.details.originalMessage ?? event.exception.type,
rawBody: ProviderShared.encodeJson(event),
}),
})
if (!event.chunk) return yield* ProviderShared.eventError(ID, "Bedrock Messages event is missing its chunk")
const text = yield* Effect.fromResult(Encoding.decodeBase64String(event.chunk.bytes)).pipe(
Effect.mapError((cause) =>
ProviderShared.eventError(ID, "Invalid Bedrock Messages chunk encoding", undefined, cause),
),
)
const decoded = yield* Schema.decodeUnknownEffect(AnthropicMessages.protocol.stream.event)(text).pipe(
Effect.mapError((cause) => ProviderShared.eventError(ID, "Invalid Bedrock Messages event", undefined, cause)),
)
return yield* AnthropicMessages.protocol.stream.step(state, decoded)
}),
},
})
function mediaIssue(
block: AnthropicMessages.AnthropicMessagesBody["messages"][number]["content"][number],
): string | undefined {
if (block.type === "tool_result")
return typeof block.content === "string"
? undefined
: block.content.map(mediaIssue).find((issue) => issue !== undefined)
if (block.type !== "image" && block.type !== "document") return undefined
if (block.source.type === "url" || block.source.type === "file")
return "Bedrock Messages does not support URL or file-ID media sources"
if (
block.type === "image" &&
!["image/jpeg", "image/png", "image/webp", "image/gif"].includes(block.source.media_type)
)
return "Bedrock Messages requires a JPEG, PNG, WebP, or GIF image"
if (block.source.type === "base64" && Encoding.decodeBase64(block.source.data)._tag === "Failure")
return "Bedrock Messages media data must be valid base64"
return undefined
}
export const route = Route.make({
id: ID,
provider: "amazon-bedrock",
providerMetadataKey: "anthropic",
protocol,
endpoint: Endpoint.path(
({ request }) => `/model/${encodeURIComponent(request.model.id)}/invoke-with-response-stream`,
{ baseURL: "https://bedrock-runtime.us-east-1.amazonaws.com" },
),
auth: BedrockAuth.auth,
framing: BedrockEventStream.framing(ID),
})
export * as BedrockMessages from "./bedrock-messages.js"
+11 -2
View File
@@ -609,18 +609,27 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
}
const step = (state: ParserState, event: GeminiEvent) => {
if (ProviderShared.isRecord(event.error) && typeof event.error.message === "string") {
if (ProviderShared.isRecord(event.error)) {
const body = ProviderShared.encodeJson(event)
return Effect.fail(
new AIError({
reason: classifyProviderFailure({
message: event.error.message,
message:
typeof event.error.message === "string" && event.error.message.length > 0
? event.error.message
: typeof event.error.status === "string" && event.error.status.length > 0
? event.error.status
: "Gemini provider error",
status: typeof event.error.code === "number" ? event.error.code : undefined,
rawBody: body,
}),
}),
)
}
if ("error" in event)
return Effect.fail(
ProviderShared.eventError(state.route, `Invalid ${state.route} stream event`, ProviderShared.encodeJson(event)),
)
const nextState = {
...state,
promptFeedback: event.promptFeedback ?? state.promptFeedback,
-1
View File
@@ -1,6 +1,5 @@
export * as AnthropicMessages from "./anthropic-messages.js"
export * as BedrockConverse from "./bedrock-converse.js"
export { BedrockMessages } from "./bedrock-messages.js"
export * as Gemini from "./gemini.js"
export * as MistralChat from "./mistral-chat.js"
export * as OpenAIChat from "./openai-chat.js"
+100 -173
View File
@@ -32,19 +32,17 @@ export const PATH = "/responses"
// =============================================================================
// Request Body Schema
// =============================================================================
export const OpenResponsesInputText = Schema.Struct({
const OpenResponsesInputText = Schema.Struct({
type: Schema.tag("input_text"),
text: Schema.String,
})
export const OpenResponsesInputImage = Schema.Struct({
const OpenResponsesInputImage = Schema.Struct({
type: Schema.tag("input_image"),
image_url: Schema.String,
detail: Schema.optional(Schema.String),
})
export const OpenResponsesInputFile = Schema.Struct({
const OpenResponsesInputFile = Schema.Struct({
type: Schema.tag("input_file"),
filename: Schema.String,
detail: Schema.optional(Schema.String),
file_data: Schema.optional(Schema.String),
file_url: Schema.optional(Schema.String),
})
@@ -56,7 +54,7 @@ const MediaInput = Schema.Union([OpenResponsesInputImage, OpenResponsesInputFile
export type MediaInput = Schema.Schema.Type<typeof MediaInput>
const OpenResponsesInputContent = Schema.Union([OpenResponsesInputText, MediaInput])
export const OpenResponsesOutputText = Schema.Struct({
const OpenResponsesOutputText = Schema.Struct({
type: Schema.tag("output_text"),
text: Schema.String,
})
@@ -64,13 +62,6 @@ export const OpenResponsesOutputText = Schema.Struct({
export const MessagePhase = Schema.NullOr(Schema.Literals(["commentary", "final_answer"]))
type MessagePhase = Schema.Schema.Type<typeof MessagePhase>
export const MessageMetadata = Schema.Struct({
itemId: Schema.optional(Schema.String),
type: Schema.optional(Schema.Literal("message")),
status: Schema.optional(Schema.String),
phase: Schema.optional(MessagePhase),
})
const messagePhase = (value: unknown): MessagePhase | undefined => {
if (value === null || value === "commentary" || value === "final_answer") return value
return undefined
@@ -81,7 +72,7 @@ const OpenResponsesReasoningSummaryText = Schema.Struct({
text: Schema.String,
})
export const OpenResponsesReasoningItem = Schema.Struct({
const OpenResponsesReasoningItem = Schema.Struct({
type: Schema.tag("reasoning"),
id: Schema.optionalKey(Schema.String),
summary: Schema.Array(OpenResponsesReasoningSummaryText),
@@ -158,30 +149,16 @@ const OpenResponsesFunctionCallOutput = Schema.Union([
Schema.Array(OpenResponsesFunctionCallOutputContent),
])
export const CompactionItem = Schema.Struct({
type: Schema.Literal("compaction"),
id: optionalNull(Schema.String),
encrypted_content: Schema.String,
})
export const InputItem = Schema.Union([
CompactionItem,
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
Schema.Struct({ role: Schema.tag("developer"), content: Schema.String }),
Schema.Struct({
role: Schema.tag("user"),
content: Schema.Array(OpenResponsesInputContent),
type: Schema.optional(Schema.Literal("message")),
id: Schema.optional(Schema.String),
status: Schema.optional(Schema.String),
}),
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenResponsesInputContent) }),
Schema.Struct({
type: Schema.tag("message"),
id: Schema.optionalKey(Schema.String),
role: Schema.tag("assistant"),
content: Schema.Array(OpenResponsesOutputText),
phase: Schema.optionalKey(MessagePhase),
status: Schema.optional(Schema.String),
}),
OpenResponsesReasoningItem,
Schema.Struct({
@@ -199,14 +176,14 @@ export const InputItem = Schema.Union([
HostedToolItem,
])
type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
export type ExtendedHostedToolItem = {
export type HostedToolReplayItem = {
readonly type: string
readonly id: string
readonly [key: string]: unknown
}
type LoweredInputItem =
| OpenResponsesInputItem
| ExtendedHostedToolItem
| HostedToolReplayItem
| {
readonly type: "message"
readonly id?: string
@@ -290,7 +267,7 @@ const OpenResponsesBody = Schema.Struct({
})
export type OpenResponsesBody = Schema.Schema.Type<typeof OpenResponsesBody>
export const OpenResponsesUsage = Schema.Struct({
const OpenResponsesUsage = Schema.Struct({
input_tokens: Schema.optional(Schema.Number),
input_tokens_details: optionalNull(
Schema.Struct({
@@ -396,7 +373,7 @@ export const Event = Schema.StructWithRest(
)
export type Event = Schema.Schema.Type<typeof Event>
export interface Extension {
export interface ProviderAdapter {
readonly id: string
readonly name: string
readonly lowerMedia?: (input: {
@@ -404,14 +381,12 @@ export interface Extension {
readonly media: ProviderShared.NormalizedMedia
readonly request: LLMRequest
}) => MediaInput | undefined
readonly lowerHostedToolItem?: (item: unknown) => ExtendedHostedToolItem | undefined
readonly restoreHostedToolItem?: (item: unknown) => HostedToolReplayItem | undefined
}
const BASE: Extension = { id: ADAPTER, name: NAME }
const BASE_ADAPTER: ProviderAdapter = { id: ADAPTER, name: NAME }
export interface ParserState {
readonly provider: LLMRequest["model"]["provider"]
readonly completedCompactions: ReadonlySet<string>
readonly id: string
readonly name: string
readonly providerMetadataKey: string
@@ -422,6 +397,9 @@ export interface ParserState {
readonly lifecycle: Lifecycle.State
readonly outputItems: Readonly<Record<number, string>>
readonly message: { readonly id: string; readonly phase: MessagePhase | null | undefined } | undefined
// Item ids are response-scoped identities. Keep completed ids tombstoned so
// reconnect replay cannot reopen fragments already emitted downstream.
readonly completedMessages: ReadonlySet<string>
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
}
@@ -507,15 +485,12 @@ const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenR
const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
part: MediaPart,
request: LLMRequest,
extension: Extension,
adapter: ProviderAdapter,
target: "message" | "tool-result",
) {
const media = ProviderShared.normalizeMedia(part)
const extended = extension.lowerMedia?.({ part, media, request })
if (extended) return extended
const detail = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenResponsesInputImage.fields.detail))(
part.providerMetadata?.[metadataKey(request.model)]?.detail,
)
const providerMedia = adapter.lowerMedia?.({ part, media, request })
if (providerMedia) return providerMedia
const url =
typeof part.data === "string" && (part.data.startsWith("https://") || part.data.startsWith("http://"))
? part.data
@@ -526,31 +501,26 @@ const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
return {
type: "input_file" as const,
filename: part.filename ?? (media.mime === "application/pdf" ? "document.pdf" : "file"),
detail,
...(url ? { file_url: url } : { file_data: media.dataUrl }),
}
}
return {
type: "input_image" as const,
image_url: url ?? media.dataUrl,
detail,
}
return { type: "input_image" as const, image_url: url ?? media.dataUrl }
})
const lowerUserContent = Effect.fnUntraced(function* (
part: LLMRequest["messages"][number]["content"][number],
request: LLMRequest,
extension: Extension,
adapter: ProviderAdapter,
) {
if (part.type === "text") return { type: "input_text" as const, text: part.text }
if (part.type === "media") return yield* lowerMessageMedia(part, request, extension)
return yield* ProviderShared.unsupportedContent(extension.name, "user", ["text", "media"])
if (part.type === "media") return yield* lowerMessageMedia(part, request, adapter)
return yield* ProviderShared.unsupportedContent(adapter.name, "user", ["text", "media"])
})
const lowerMessageMedia = Effect.fnUntraced(function* (part: MediaPart, request: LLMRequest, extension: Extension) {
const lowered = yield* lowerMedia(part, request, extension, "message")
const lowerMessageMedia = Effect.fnUntraced(function* (part: MediaPart, request: LLMRequest, adapter: ProviderAdapter) {
const lowered = yield* lowerMedia(part, request, adapter, "message")
if (lowered.type === "input_video")
return yield* ProviderShared.invalidRequest(`${extension.name} user messages do not support input_video`)
return yield* ProviderShared.invalidRequest(`${adapter.name} user messages do not support input_video`)
return lowered
})
@@ -559,13 +529,13 @@ const lowerMessageMedia = Effect.fnUntraced(function* (part: MediaPart, request:
const lowerToolResultContentItem = Effect.fnUntraced(function* (
item: Content,
request: LLMRequest,
extension: Extension,
adapter: ProviderAdapter,
) {
if (item.type === "text") return { type: "input_text" as const, text: item.text }
return yield* lowerMedia(
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
request,
extension,
adapter,
"tool-result",
)
})
@@ -573,49 +543,48 @@ const lowerToolResultContentItem = Effect.fnUntraced(function* (
const lowerHostedToolResultContentItem = Effect.fnUntraced(function* (
item: Content,
request: LLMRequest,
extension: Extension,
adapter: ProviderAdapter,
) {
if (item.type === "text") return { type: "input_text" as const, text: item.text }
return yield* lowerMessageMedia(
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
request,
extension,
adapter,
)
})
const lowerToolResultOutput = Effect.fnUntraced(function* (
part: ToolResultPart,
request: LLMRequest,
extension: Extension,
adapter: ProviderAdapter,
) {
// Text/json/error results are encoded as a plain string for backward
// compatibility with existing cassettes and provider expectations.
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
// Preserve the narrowed array element type when compiled through a consumer package.
const content: ReadonlyArray<Content> = part.result.value
return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension))
return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, adapter))
})
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (request: LLMRequest, extension: Extension) {
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
request: LLMRequest,
adapter: ProviderAdapter,
) {
const input: LoweredInputItem[] = []
const providerMetadataKey = metadataKey(request.model)
const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
for (const message of request.messages) {
const metadata = yield* ProviderShared.validateWith(
Schema.decodeUnknownEffect(Schema.UndefinedOr(MessageMetadata)),
)(message.providerMetadata?.[providerMetadataKey])
if (message.role === "system") {
input.push({
role: "developer",
content: ProviderShared.joinText(yield* ProviderShared.systemUpdateText(extension.name, message)),
content: ProviderShared.joinText(yield* ProviderShared.systemUpdateText(adapter.name, message)),
})
continue
}
if (message.role === "user") {
const content = yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request, extension))
if (content.length > 0)
input.push({ role: "user", content, type: metadata?.type, id: metadata?.itemId, status: metadata?.status })
const content = yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request, adapter))
if (content.length > 0) input.push({ role: "user", content })
continue
}
@@ -628,10 +597,9 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
const groups = content.reduce<
Array<{ id: string | undefined; phase: MessagePhase | null | undefined; parts: TextPart[] }>
>((groups, part) => {
const partMetadata = part.providerMetadata?.[providerMetadataKey]
const id = itemID(part.providerMetadata, providerMetadataKey) ?? metadata?.itemId
const partPhase = messagePhase(partMetadata?.phase)
const phase = partPhase === undefined ? metadata?.phase : partPhase
const metadata = part.providerMetadata?.[providerMetadataKey]
const id = itemID(part.providerMetadata, providerMetadataKey)
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase) : undefined
const group = groups.at(-1)
if (group && group.id === id && group.phase === phase) group.parts.push(part)
else groups.push({ id, phase, parts: [part] })
@@ -642,7 +610,6 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
type: "message" as const,
...(group.id === undefined ? {} : { id: group.id }),
role: "assistant" as const,
status: metadata?.status,
content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
...(group.phase === undefined ? {} : { phase: group.phase }),
})),
@@ -650,15 +617,6 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
content.splice(0, content.length)
}
for (const part of message.content) {
if (part.type === "compaction") {
flushText()
if (part.provider !== request.model.provider || part.encrypted === undefined)
return yield* ProviderShared.invalidRequest(
"Compaction state must be replayed to its originating provider and API",
)
input.push({ type: "compaction", id: part.id, encrypted_content: part.encrypted })
continue
}
if (part.type === "text") {
content.push(part)
continue
@@ -692,7 +650,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
? undefined
: Schema.is(HostedToolItem)(part.result.value)
? part.result.value
: extension.lowerHostedToolItem?.(part.result.value)
: adapter.restoreHostedToolItem?.(part.result.value)
if (id !== undefined && hosted?.id === id) {
if (!hostedToolItems.has(id)) {
input.push(hosted)
@@ -706,13 +664,11 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
: [{ type: "text", text: ProviderShared.toolResultText(part) }]
input.push({
role: "user",
content: yield* Effect.forEach(content, (item) =>
lowerHostedToolResultContentItem(item, request, extension),
),
content: yield* Effect.forEach(content, (item) => lowerHostedToolResultContentItem(item, request, adapter)),
})
continue
}
return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [
return yield* ProviderShared.unsupportedContent(adapter.name, "assistant", [
"text",
"reasoning",
"tool-call",
@@ -725,11 +681,11 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
for (const part of message.content) {
if (!ProviderShared.supportsContent(part, ["tool-result"]))
return yield* ProviderShared.unsupportedContent(extension.name, "tool", ["tool-result"])
return yield* ProviderShared.unsupportedContent(adapter.name, "tool", ["tool-result"])
input.push({
type: "function_call_output",
call_id: part.id,
output: yield* lowerToolResultOutput(part, request, extension),
output: yield* lowerToolResultOutput(part, request, adapter),
})
}
}
@@ -737,30 +693,13 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
return input
})
export const lowerConversation = Effect.fn("OpenResponses.lowerConversation")(function* (
request: LLMRequest,
extension: Extension,
) {
const instructions = ProviderShared.joinText(request.system)
return {
model: request.model.id,
input: yield* lowerMessages(request, extension),
...(instructions ? { instructions } : {}),
}
})
export const lowerGeneration = (request: LLMRequest) => {
const lowerOptions = (request: LLMRequest) => {
const options = OpenResponsesOptions.resolve(request)
const generation = request.generation
const instructions = ProviderShared.joinText(request.system)
const cacheKey = ProviderShared.promptCacheKey(request)
const parallelToolCalls = resolveParallelToolCalls(request)
return {
stream: true as const,
max_output_tokens: generation?.maxTokens,
temperature: generation?.temperature,
top_p: generation?.topP,
presence_penalty: generation?.presencePenalty,
frequency_penalty: generation?.frequencyPenalty,
...(instructions ? { instructions } : {}),
...(options.store !== undefined ? { store: options.store } : {}),
...(options.metadata ? { metadata: options.metadata } : {}),
...(options.safetyIdentifier ? { safety_identifier: options.safetyIdentifier } : {}),
@@ -788,7 +727,7 @@ export const resolveParallelToolCalls = (request: LLMRequest) => {
return disabled === undefined ? undefined : !disabled
}
export const allowedToolChoice = (request: LLMRequest) => {
const allowedToolChoice = (request: LLMRequest) => {
const allowed = OpenResponsesOptions.resolve(request).allowedTools
if (!allowed) return undefined
return {
@@ -798,34 +737,42 @@ export const allowedToolChoice = (request: LLMRequest) => {
}
}
export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWithExtension")(function* (
export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAdapter")(function* (
request: LLMRequest,
extension: Extension,
adapter: ProviderAdapter,
) {
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
return {
...(yield* lowerConversation(request, extension)),
...lowerGeneration(request),
model: request.model.id,
input: yield* lowerMessages(request, adapter),
tools:
request.tools.length === 0
? undefined
: yield* Effect.forEach(request.tools, (tool) =>
lowerTool(
extension.name,
adapter.name,
tool,
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
),
),
tool_choice:
allowedToolChoice(request) ??
(request.toolChoice ? yield* lowerToolChoice(extension.name, request.toolChoice) : undefined),
(request.toolChoice ? yield* lowerToolChoice(adapter.name, request.toolChoice) : undefined),
stream: true as const,
max_output_tokens: generation?.maxTokens,
temperature: generation?.temperature,
top_p: generation?.topP,
presence_penalty: generation?.presencePenalty,
frequency_penalty: generation?.frequencyPenalty,
...lowerOptions(request),
}
})
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenResponsesBody))
export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (request: LLMRequest) {
return yield* decodeBody(yield* fromRequestWithExtension(request, BASE))
return yield* decodeBody(yield* fromRequestWithAdapter(request, BASE_ADAPTER))
})
// =============================================================================
@@ -835,7 +782,7 @@ export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (req
// cached-read and cache-write subsets, and `output_tokens` (inclusive total)
// with a `reasoning_tokens` subset. Pass the totals through and derive the
// non-cached breakdown.
export const mapUsage = (usage: OpenResponsesUsage | null | undefined, providerMetadataKey: string) => {
const mapUsage = (usage: OpenResponsesUsage | null | undefined, providerMetadataKey: string) => {
if (!usage) return undefined
const cached = usage.input_tokens_details?.cached_tokens
const cacheWrite = usage.input_tokens_details?.cache_write_tokens
@@ -865,8 +812,6 @@ const mapFinishReason = (event: Event, hasFunctionCall: boolean): FinishReason =
return hasFunctionCall ? "tool-calls" : "unknown"
}
export const metadataKey = (model: LLMRequest["model"]) => model.route.providerMetadataKey ?? "openresponses"
export const providerMetadata = (state: ParserState, metadata: Record<string, unknown>): ProviderMetadata => ({
[state.providerMetadataKey]: metadata,
})
@@ -1010,12 +955,16 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
const item = event.item
if (item?.type === "message" && item.id !== undefined) {
const itemID = item.id
if (state.completedMessages.has(itemID)) return [state, NO_EVENTS]
const phase = messagePhase(item.phase)
const completedMessages = new Set(state.completedMessages)
if (state.message !== undefined && state.message.id !== itemID) completedMessages.add(state.message.id)
// A new message closes earlier messages, including ones that never streamed.
const events: LLMEvent[] = []
const lifecycle = [...state.lifecycle.text]
.filter((id) => id !== itemID)
.reduce((lifecycle, id) => {
completedMessages.add(id)
const openPhase = state.message?.id === id ? state.message.phase : undefined
return Lifecycle.textEnd(
lifecycle,
@@ -1028,6 +977,7 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
{
...state,
lifecycle,
completedMessages,
message: {
id: itemID,
phase: phase === undefined && state.message?.id === itemID ? state.message.phase : phase,
@@ -1143,27 +1093,13 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
) {
if (!item) return [state, NO_EVENTS] satisfies StepResult
if (item.type === "compaction") {
if (!item.id || typeof item.encrypted_content !== "string")
return yield* ProviderShared.eventError(state.id, "Compaction output is missing its id or encrypted content")
if (state.completedCompactions.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
events.push(
LLMEvent.compaction({
provider: state.provider,
id: item.id,
encrypted: item.encrypted_content,
}),
)
return [
{ ...state, lifecycle, completedCompactions: new Set([...state.completedCompactions, item.id]) },
events,
] satisfies StepResult
}
if (item.type === "message" && item.id !== undefined) {
const message = state.message?.id === item.id ? state.message : undefined
if (state.completedMessages.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
const completedMessages = new Set(state.completedMessages)
completedMessages.add(item.id)
if (state.message !== undefined && state.message.id !== item.id)
return [{ ...state, completedMessages }, NO_EVENTS] satisfies StepResult
const message = state.message
const itemPhase = messagePhase(item.phase)
const phase = itemPhase === undefined ? message?.phase : itemPhase
const parts: ReadonlyArray<unknown> = Array.isArray(item.content) ? item.content : []
@@ -1176,13 +1112,13 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
const text = content.length > 0 ? content.join("") : undefined
const metadata = providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) })
const events: LLMEvent[] = []
const lifecycle =
message && text ? Lifecycle.textStart(state.lifecycle, events, item.id, metadata) : state.lifecycle
const lifecycle = text ? Lifecycle.textStart(state.lifecycle, events, item.id, metadata) : state.lifecycle
return [
{
...state,
lifecycle: Lifecycle.textEnd(lifecycle, events, item.id, metadata, text),
message: message ? undefined : state.message,
completedMessages,
message: undefined,
},
events,
] satisfies StepResult
@@ -1321,34 +1257,26 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
const events: LLMEvent[] = []
if (event.type === "response.completed") {
for (const item of event.response?.output ?? []) {
if (item.type !== "compaction" && item.type !== "function_call") continue
if (item.type === "compaction") {
// Terminal recovery cannot insert a checkpoint before already-emitted content.
if (state.lifecycle.stepStarted && !state.completedCompactions.has(item.id ?? ""))
return yield* ProviderShared.eventError(
state.id,
"Cannot recover a compaction checkpoint after output has been emitted",
)
}
if (item.type === "function_call" && !current.tools[item.id ?? item.call_id ?? ""]) continue
const id = item.id ?? (item.type === "function_call" ? item.call_id : undefined)
if (id === undefined) continue
if (item.type !== "function_call" || !current.tools[id]) continue
const [next, emitted] = yield* onOutputItemDone(current, item)
current = next
events.push(...emitted)
}
// Some compatible providers omit output_item.done even after completing the response.
const pending = yield* ToolStream.finishAll(current.id, current.tools)
current = {
...current,
tools: pending.tools,
hasFunctionCall:
current.hasFunctionCall ||
pending.events.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)),
}
events.push(...pending.events)
}
// Some compatible providers omit output_item.done even after completing the response.
const pending =
event.type === "response.completed"
? yield* ToolStream.finishAll(current.id, current.tools)
: { tools: current.tools, events: NO_EVENTS }
events.push(...pending.events)
const hasFunctionCall =
pending.events.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
current.hasFunctionCall
const lifecycle = Lifecycle.finish(current.lifecycle, events, {
reason: {
normalized: mapFinishReason(event, current.hasFunctionCall),
normalized: mapFinishReason(event, hasFunctionCall),
raw: event.response?.incomplete_details?.reason,
},
usage: mapUsage(event.response?.usage, current.providerMetadataKey),
@@ -1360,7 +1288,7 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
})
: undefined,
})
return [{ ...current, lifecycle }, events] satisfies StepResult
return [{ ...current, lifecycle, hasFunctionCall, tools: pending.tools }, events] satisfies StepResult
})
// Build the prettiest summary available from whatever the provider supplied.
@@ -1494,18 +1422,17 @@ export const step = (state: ParserState, input: Event) => {
* The provider-neutral Open Responses protocol. Provider-specific Responses
* implementations compose this baseline with their own tools and event variants.
*/
export const initial = (request: LLMRequest, extension: Extension = BASE): ParserState => ({
provider: request.model.provider,
completedCompactions: new Set<string>(),
id: extension.id,
name: extension.name,
providerMetadataKey: metadataKey(request.model),
export const initial = (request: LLMRequest, adapter: ProviderAdapter = BASE_ADAPTER): ParserState => ({
id: adapter.id,
name: adapter.name,
providerMetadataKey: request.model.route.providerMetadataKey ?? "openresponses",
hasFunctionCall: false,
tools: ToolStream.empty<string>(),
completedTools: new Set<string>(),
lifecycle: Lifecycle.initial(),
outputItems: {},
message: undefined,
completedMessages: new Set<string>(),
reasoningItems: {},
})
+13 -31
View File
@@ -5,14 +5,13 @@ import { Auth } from "../route/auth.js"
import { Endpoint } from "../route/endpoint.js"
import { Protocol } from "../route/protocol.js"
import { HttpTransport } from "../route/transport/index.js"
import type { LLMRequest, JsonSchema, ToolDefinition } from "../schema/index.js"
import { LLMRequest, type JsonSchema, type ToolDefinition } from "../schema/index.js"
import { OpenResponses } from "./open-responses.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { OpenAIImage } from "./utils/openai-image.js"
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
import { OpenResponsesChannel } from "./open-responses-channel.js"
import { ResponsesCompaction } from "./utils/responses-compaction.js"
const ADAPTER = "openai-responses"
const NAME = "OpenAI Responses"
@@ -21,14 +20,6 @@ const WEBSOCKET_ROTATE_AFTER_MS = 55 * 60 * 1000
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
export const PATH = OpenResponses.PATH
export const ContextManagement = Schema.Array(
Schema.Struct({
type: Schema.Literal("compaction"),
compactThreshold: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))),
}),
)
export type ContextManagement = typeof ContextManagement.Type
const OpenAIResponsesImageGenerationTool = Schema.Struct({
type: Schema.tag("image_generation"),
action: Schema.optional(Schema.Literals(["auto", "generate", "edit"])),
@@ -87,14 +78,6 @@ const OpenAIResponsesCoreFields = {
input: Schema.Array(Schema.Union([OpenResponses.InputItem, OpenAIResponsesHostedToolItem])),
tools: optionalArray(OpenAIResponsesTools),
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
context_management: Schema.optional(
Schema.Array(
Schema.Struct({
type: Schema.Literal("compaction"),
compact_threshold: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))),
}),
),
),
}
const OpenAIResponsesBody = Schema.Struct({
@@ -103,11 +86,11 @@ const OpenAIResponsesBody = Schema.Struct({
})
export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
const extension = {
const adapter = {
id: ADAPTER,
name: NAME,
lowerHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.Extension
restoreHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.ProviderAdapter
const nativeImageToolInput = (tool: ToolDefinition) => {
const native = tool.native?.openai
@@ -142,14 +125,15 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tool
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesBody))
const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) {
const management = yield* ProviderShared.validateWith(
Schema.decodeUnknownEffect(Schema.UndefinedOr(ContextManagement)),
)(request.providerOptions?.contextManagement)
const body = yield* OpenResponses.fromRequestWithAdapter(
LLMRequest.update(request, { tools: [], toolChoice: undefined }),
adapter,
)
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const parallelToolCalls = OpenResponses.resolveParallelToolCalls(request)
return yield* decodeBody({
...(yield* OpenResponses.lowerConversation(request, extension)),
...OpenResponses.lowerGeneration(request),
context_management: management?.map((edit) => ({ type: edit.type, compact_threshold: edit.compactThreshold })),
...body,
...(parallelToolCalls === undefined ? {} : { parallel_tool_calls: parallelToolCalls }),
tools:
request.tools.length === 0
? undefined
@@ -157,8 +141,7 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
),
tool_choice:
OpenResponses.allowedToolChoice(request) ??
(request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined),
body.tool_choice ?? (request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined),
})
})
@@ -221,7 +204,7 @@ export const protocol = Protocol.make({
},
stream: {
event: OpenResponses.protocol.stream.event,
initial: (request) => OpenResponses.initial(request, extension),
initial: (request) => OpenResponses.initial(request, adapter),
step,
terminal: OpenResponses.terminal,
},
@@ -240,7 +223,6 @@ export const transport = channelTransport({
})
export const route = Route.make({
compact: ResponsesCompaction.make(extension),
id: ADAPTER,
provider: "openai",
providerMetadataKey: "openai",
@@ -1,174 +0,0 @@
import { Effect, Schema, Stream } from "effect"
import {
AIError,
InvalidProviderOutputError,
CompactionPart,
CompactionResponse,
HttpOptions,
LLMRequest,
Message,
type ContentPart,
mergeJsonRecords,
} from "../../schema/index.js"
import type { CompactOperation } from "../../route/client.js"
import { Endpoint } from "../../route/endpoint.js"
import { RequestExecutor } from "../../route/executor.js"
import { HttpTransport } from "../../route/transport/index.js"
import { OpenResponses } from "../open-responses.js"
import { JsonObject, optionalNull, ProviderShared } from "../shared.js"
const Body = Schema.Struct({
model: Schema.String,
input: Schema.Array(Schema.Unknown),
instructions: optionalNull(Schema.String),
previous_response_id: optionalNull(Schema.String),
service_tier: optionalNull(Schema.String),
prompt_cache_key: optionalNull(Schema.String),
prompt_cache_retention: optionalNull(Schema.String),
prompt_cache_options: optionalNull(
Schema.Struct({ mode: Schema.optional(Schema.String), ttl: Schema.optional(Schema.String) }),
),
})
const Text = Schema.Union([OpenResponses.OpenResponsesInputText, OpenResponses.OpenResponsesOutputText])
const File = Schema.Union([
Schema.Struct({
...OpenResponses.OpenResponsesInputFile.fields,
file_url: Schema.String,
file_data: Schema.optional(Schema.Never),
}),
Schema.Struct({
...OpenResponses.OpenResponsesInputFile.fields,
file_data: Schema.String,
file_url: Schema.optional(Schema.Never),
}),
])
const MessageFields = {
type: Schema.Literal("message"),
id: Schema.optional(Schema.String),
status: Schema.optional(Schema.String),
phase: Schema.optional(OpenResponses.MessagePhase),
}
const Response = Schema.Struct({
object: Schema.Literal("response.compaction"),
output: Schema.Array(
Schema.Union([
OpenResponses.CompactionItem,
OpenResponses.OpenResponsesReasoningItem,
Schema.Struct({
...MessageFields,
role: Schema.Literal("user"),
content: Schema.Array(Schema.Union([Text, OpenResponses.OpenResponsesInputImage, File])).check(
Schema.isMinLength(1),
),
}),
Schema.Struct({
...MessageFields,
role: Schema.Literal("assistant"),
content: Schema.Array(Text).check(Schema.isMinLength(1)),
}),
]),
),
usage: Schema.optional(Schema.StructWithRest(OpenResponses.OpenResponsesUsage, [JsonObject])),
})
export const make = (extension: OpenResponses.Extension): CompactOperation =>
Effect.fn("ResponsesCompaction.execute")(function* (request, executor, options) {
const route = request.model.route
const native = yield* OpenResponses.lowerConversation(request, extension)
const body = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Body))(
mergeJsonRecords(
{
...native,
service_tier: request.providerOptions?.serviceTier,
prompt_cache_key: ProviderShared.promptCacheKey(request),
},
request.http?.body,
),
)
const url = Endpoint.render(route.endpoint, { request, body: native })
url.pathname = `${url.pathname.replace(/\/$/, "")}/compact`
const parts = yield* HttpTransport.jsonRequestParts({
request: LLMRequest.update(request, {
http: request.http === undefined ? undefined : new HttpOptions({ ...request.http, body: undefined }),
}),
body,
endpoint: Endpoint.path(url.toString()),
auth: route.auth,
encodeBody: Schema.encodeSync(Schema.fromJsonString(Body)),
})
const response = yield* executor.execute(
ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }),
options?.http,
)
const text = yield* RequestExecutor.responseStream(response).pipe(
Stream.decodeText(),
Stream.runFold(
() => "",
(text, chunk) => text + chunk,
),
)
const invalid = (message: string, cause?: unknown) =>
new AIError({
reason: new InvalidProviderOutputError({
route: route.id,
message,
body: text,
cause,
http: RequestExecutor.responseHttp(response),
}),
})
const result = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Response))(text).pipe(
Effect.mapError((cause) => invalid("Invalid compaction response", cause)),
)
if (!result.output.some((item) => item.type === "compaction"))
return yield* invalid("Compaction response did not contain a checkpoint")
return new CompactionResponse({
messages: result.output.map((item) => toMessage(item, request.model)),
usage: OpenResponses.mapUsage(result.usage, OpenResponses.metadataKey(request.model)),
})
})
function toMessage(item: (typeof Response.Type.output)[number], model: LLMRequest["model"]): Message {
if (item.type === "compaction")
return Message.assistant(
CompactionPart.make({ provider: model.provider, id: item.id ?? undefined, encrypted: item.encrypted_content }),
)
const key = OpenResponses.metadataKey(model)
if (item.type === "reasoning") {
const summary = item.summary.length ? item.summary : [{ text: "" }]
return Message.assistant(
summary.map((part) => ({
type: "reasoning" as const,
text: part.text,
providerMetadata: { [key]: { itemId: item.id, reasoningEncryptedContent: item.encrypted_content } },
})),
)
}
return Message.make({
role: item.role,
providerMetadata: { [key]: { itemId: item.id, type: item.type, status: item.status, phase: item.phase } },
content: item.content.map((part): ContentPart => {
if (part.type === "input_text" || part.type === "output_text") return { type: "text", text: part.text }
if (part.type === "input_image")
return {
type: "media",
data: part.image_url,
mediaType: /^data:([^;,]+)/.exec(part.image_url)?.[1] ?? "image/*",
providerMetadata: part.detail === undefined ? undefined : { [key]: { detail: part.detail } },
}
const data = part.file_url === undefined ? part.file_data : part.file_url
return {
type: "media",
data,
filename: part.filename,
mediaType: /^data:([^;,]+)/.exec(data)?.[1] ?? "application/octet-stream",
providerMetadata: part.detail === undefined ? undefined : { [key]: { detail: part.detail } },
}
}),
})
}
export * as ResponsesCompaction from "./responses-compaction.js"
+5 -12
View File
@@ -4,7 +4,6 @@ import type { LLMRequest } from "../schema/index.js"
import { OpenResponses } from "./open-responses.js"
import { JsonObject, optionalNull, ProviderShared } from "./shared.js"
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
import { ResponsesCompaction } from "./utils/responses-compaction.js"
const ADAPTER = "xai-responses"
const NAME = "xAI Responses"
@@ -37,19 +36,15 @@ const XAIResponsesBody = Schema.Struct({
stream: Schema.Literal(true),
})
const extension = {
const adapter = {
id: ADAPTER,
name: NAME,
lowerHostedToolItem: (item: unknown) => (Schema.is(XAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.Extension
restoreHostedToolItem: (item: unknown) => (Schema.is(XAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.ProviderAdapter
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(XAIResponsesBody))
const fromRequest = Effect.fn("XAIResponses.fromRequest")(function* (request: LLMRequest) {
if (request.providerOptions?.contextManagement !== undefined)
return yield* ProviderShared.invalidRequest(
"xAI requires explicit compaction through LLMClient.compact; automatic context management is not supported",
)
return yield* decodeBody(yield* OpenResponses.fromRequestWithExtension(request, extension))
return yield* decodeBody(yield* OpenResponses.fromRequestWithAdapter(request, adapter))
})
const HOSTED_TOOLS = {
@@ -83,12 +78,10 @@ export const protocol = Protocol.make({
},
stream: {
event: OpenResponses.protocol.stream.event,
initial: (request) => OpenResponses.initial(request, extension),
initial: (request) => OpenResponses.initial(request, adapter),
step,
terminal: OpenResponses.terminal,
},
})
export const compact = ResponsesCompaction.make(extension)
export * as XAIResponses from "./xai-responses.js"
+1 -3
View File
@@ -1,5 +1,4 @@
import type { LanguageModel, ProviderOptions } from "./schema/index.js"
import type { CompactOperation } from "./route/client.js"
export interface Settings extends Readonly<Record<string, unknown>> {
readonly baseURL?: string
@@ -10,9 +9,8 @@ export interface Settings extends Readonly<Record<string, unknown>> {
export interface Definition<
ProviderSettings extends Settings = Settings,
Options extends ProviderOptions = ProviderOptions,
Compact extends CompactOperation | undefined = CompactOperation | undefined,
> {
readonly model: (modelID: string, settings: ProviderSettings) => LanguageModel<Options, Compact>
readonly model: (modelID: string, settings: ProviderSettings) => LanguageModel<Options>
}
export * as ProviderPackage from "./provider-package.js"
+9 -21
View File
@@ -1,11 +1,9 @@
import type { Route, RouteDefaultsInput } from "../route/client.js"
import type { RouteDefaultsInput } from "../route/client.js"
import { Auth } from "../route/auth.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import * as BedrockConverse from "../protocols/bedrock-converse.js"
import type { BedrockCredentials } from "../protocols/bedrock-converse.js"
import { BedrockMessages } from "../protocols/bedrock-messages.js"
import type { AnthropicMessages } from "../protocols/anthropic-messages.js"
export const id = ProviderID.make("amazon-bedrock")
@@ -27,40 +25,38 @@ export interface Settings extends ProviderPackage.Settings {
readonly region?: string
readonly topP?: number
}
export const routes = [BedrockConverse.route, BedrockMessages.route]
export const routes = [BedrockConverse.route]
const bedrockBaseURL = (region: string) => `https://bedrock-runtime.${region}.amazonaws.com`
const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Config) => {
const configuredRoute = (input: Config) => {
const { apiKey, credentials, region, baseURL, ...rest } = input
const resolvedRegion = region ?? credentials?.region ?? "us-east-1"
return route.with({
return BedrockConverse.route.with({
...rest,
provider: id,
providerMetadataKey: route.providerMetadataKey,
providerMetadataKey: "bedrock",
endpoint: { baseURL: baseURL ?? bedrockBaseURL(resolvedRegion) },
auth: apiKey === undefined ? BedrockConverse.sigV4Auth(credentials) : Auth.bearer(apiKey),
})
}
export const configure = (input: Config = {}) => {
const route = configuredRoute(BedrockConverse.route, input)
const messages = configuredRoute(BedrockMessages.route, input)
const route = configuredRoute(input)
return {
id,
model: (modelID: string | ModelID) => route.model({ id: modelID }),
messages: (modelID: string | ModelID) => messages.model<AnthropicMessages.ProviderOptionsInput>({ id: modelID }),
configure,
}
}
export const provider = configure()
const config = (settings: Settings): Config => {
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
if (settings.auth === "bearer" && settings.apiKey === undefined)
throw new Error("Amazon Bedrock bearer auth requires apiKey")
if (settings.auth === "sigv4" && settings.apiKey !== undefined)
throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey")
return {
return configure({
apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey,
baseURL: settings.baseURL,
credentials: settings.credentials,
@@ -68,13 +64,5 @@ const config = (settings: Settings): Config => {
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
region: settings.region,
}
}).model(modelID)
}
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
configure(config(settings)).model(modelID)
export const messagesModel: ProviderPackage.Definition<
Settings & { readonly providerOptions?: AnthropicMessages.ProviderOptionsInput },
AnthropicMessages.ProviderOptionsInput
>["model"] = (modelID, settings) =>
configure({ ...config(settings), providerOptions: settings.providerOptions }).messages(modelID)
@@ -1 +0,0 @@
export { messagesModel as model } from "../amazon-bedrock.js"
+6 -11
View File
@@ -1,7 +1,7 @@
import { Headers } from "effect/unstable/http"
import { Auth } from "../route/auth.js"
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
import type { Route, RouteDefaultsInput, CompactOperation } from "../route/client.js"
import type { Route as RouteDef, RouteDefaultsInput } from "../route/client.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import * as OpenAIChat from "../protocols/openai-chat.js"
@@ -102,11 +102,7 @@ const auth = (input: Config) => {
)
}
const configuredRoute = <Body, Prepared, Compact extends CompactOperation | undefined>(
route: Route<Body, Prepared, Compact>,
input: Config,
modelID: string | ModelID,
) =>
const configuredRoute = <Body, Prepared>(route: RouteDef<Body, Prepared>, input: Config, modelID: string | ModelID) =>
route.with({
auth: auth(input),
endpoint: endpoint(input, modelID),
@@ -165,11 +161,10 @@ const config = (settings: Settings): Config => {
throw new Error("Azure requires resourceName or baseURL")
}
export const responsesModel: ProviderPackage.Definition<
Settings,
OpenAIProviderOptionsInput,
CompactOperation
>["model"] = (modelID, settings) => configure(config(settings)).responses(modelID)
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
) => configure(config(settings)).responses(modelID)
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
@@ -57,9 +57,7 @@ const route = Route.make({
}),
endpoint: Endpoint.path(({ request }) => `/${request.model.id}:streamRawPredict`),
auth: Auth.none,
transport: AnthropicMessages.transport<
Omit<AnthropicMessages.AnthropicMessagesBody, "model"> & { readonly anthropic_version: typeof VERSION }
>(),
framing: AnthropicMessages.framing,
headers: () => ({ "anthropic-version": HEADER_VERSION }),
})
@@ -1,12 +1,10 @@
import { mergeProviderOptions, type ProviderOptions } from "../schema/index.js"
import type { OpenAIServiceTier } from "../protocols/utils/openai-options.js"
import type { Options } from "../protocols/utils/open-responses-options.js"
import type { ContextManagement } from "../protocols/openai-responses.js"
export type { OpenAIResponseIncludable, OpenAIServiceTier } from "../protocols/utils/openai-options.js"
export type OpenAIOptionsInput = Omit<Options, "serviceTier"> & {
readonly contextManagement?: ContextManagement
readonly serviceTier?: OpenAIServiceTier
readonly [key: string]: unknown
}
+3 -9
View File
@@ -1,5 +1,5 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import type { Route, RouteDefaultsInput, CompactOperation } from "../route/client.js"
import type { Route, RouteDefaultsInput } from "../route/client.js"
import type { ProviderPackage } from "../provider-package.js"
import { HttpOptions, ProviderID, ToolDefinition, mergeHttpOptions, type ModelID } from "../schema/index.js"
import * as OpenAIChat from "../protocols/openai-chat.js"
@@ -73,10 +73,7 @@ const defaults = (input: Config) => {
return rest
}
const configuredRoute = <Body, Prepared, Compact extends CompactOperation | undefined>(
route: Route<Body, Prepared, Compact>,
input: Config,
) =>
const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Config) =>
route.with({
auth: auth(input),
endpoint: { baseURL: input.baseURL, query: input.queryParams },
@@ -132,10 +129,7 @@ const config = (settings: Settings): Config => {
}
}
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput, CompactOperation>["model"] = (
modelID,
settings,
) => {
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
return configure(config(settings)).responses(modelID)
}
+3 -7
View File
@@ -1,5 +1,5 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput, type CompactOperation } from "../route/client.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js"
@@ -13,7 +13,7 @@ import type { ProviderPackage } from "../provider-package.js"
export const id = ProviderID.make("xai")
export type XAIProviderOptionsInput = OpenAIOptionsInput & { readonly contextManagement?: never }
export type XAIProviderOptionsInput = OpenAIOptionsInput
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
@@ -32,7 +32,6 @@ export type { XAIImageOptions } from "../protocols/xai-images.js"
const RESPONSES_WEBSOCKET_ROTATE_AFTER_MS = 24 * 60 * 1000
const responsesRoute = Route.make({
compact: XAIResponses.compact,
id: "openai-responses",
provider: id,
providerMetadataKey: "xai",
@@ -103,10 +102,7 @@ export const configure = (input: LanguageModelOptions = {}) => {
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput, CompactOperation>["model"] = (
modelID,
settings,
) =>
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
+13 -80
View File
@@ -13,7 +13,6 @@ import * as ProviderShared from "../protocols/shared.js"
import type { ProtocolID, ProviderOptions } from "../schema/index.js"
import {
AIError,
CompactionResponse,
AIErrorReason,
GenerationOptions,
HttpOptions,
@@ -35,12 +34,7 @@ export interface RouteBody<Body> {
readonly from: (request: LLMRequest) => Effect.Effect<Body, AIError>
}
export interface Route<
Body,
Prepared = unknown,
Compact extends CompactOperation | undefined = CompactOperation | undefined,
> {
readonly compact: Compact
export interface Route<Body, Prepared = unknown> {
readonly id: string
readonly provider?: ProviderID
/** ProviderMetadata namespace emitted and consumed by this route. */
@@ -48,15 +42,13 @@ export interface Route<
readonly protocol: ProtocolID
readonly endpoint: Endpoint.Definition<Body>
readonly auth: Auth.Definition
/** Deployment headers resolved once for every operation, before transport authentication. */
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
readonly transport: Transport<Body, Prepared, unknown>
readonly defaults: RouteDefaults
readonly body: RouteBody<Body>
readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared, Compact>
readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared>
readonly model: <Options extends ProviderOptions = ProviderOptions>(
input: RouteMappedLanguageModelInput,
) => LanguageModel<Options, Compact>
) => LanguageModel<Options>
readonly prepareTransport: (
body: Body,
request: LLMRequest,
@@ -74,11 +66,7 @@ export interface Route<
// Normal call sites use `OpenAIChat.route`; callers only need body types
// when preparing a request with a protocol-specific type assertion.
// oxlint-disable-next-line typescript-eslint/no-explicit-any
export type AnyRoute<Compact extends CompactOperation | undefined = CompactOperation | undefined> = Route<
any,
any,
Compact
>
export type AnyRoute = Route<any, any>
export type HttpOptionsInput = HttpOptions.Input
@@ -111,15 +99,15 @@ export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
type RouteMappedLanguageModelInput = RouteLanguageModelInput | RouteRoutedLanguageModelInput
const makeRouteLanguageModel = <Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
route: AnyRoute<Compact>,
const makeRouteLanguageModel = <Options extends ProviderOptions = ProviderOptions>(
route: AnyRoute,
mapped: RouteMappedLanguageModelInput,
) => {
const provider = route.provider ?? ("provider" in mapped ? mapped.provider : undefined)
if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`)
if (!endpointBaseURL(route.endpoint))
throw new Error(`Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`)
return LanguageModel.make<Options, Compact>({
return LanguageModel.make<Options>({
...mapped,
provider,
route,
@@ -162,10 +150,6 @@ export const httpOptions = (input: HttpOptionsInput | undefined) => {
}
export interface Interface {
readonly compact: (
request: CompactionRequest,
options?: Pick<StreamOptions, "http">,
) => Effect.Effect<CompactionResponse, AIError>
readonly stream: StreamMethod
readonly generate: GenerateMethod
}
@@ -183,17 +167,6 @@ export interface GenerateMethod {
(request: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, AIError>
}
export type CompactOperation = (
request: LLMRequest,
executor: RequestExecutor.Interface,
options?: Pick<StreamOptions, "http">,
) => Effect.Effect<CompactionResponse, AIError>
export type CompactionRequest = LLMRequest<LanguageModel<ProviderOptions, CompactOperation>>
export const canCompact = (request: LLMRequest): request is CompactionRequest =>
request.model.route.compact !== undefined
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
const resolveRequestOptions = (request: LLMRequest) => {
@@ -214,7 +187,6 @@ const resolveRequestOptions = (request: LLMRequest) => {
}
export interface MakeInput<Body, Frame, Event, State> {
readonly compact?: CompactOperation
/** Route id used in diagnostics and prepared request metadata. */
readonly id: string
/** Provider identity for route-owned model construction. */
@@ -236,7 +208,6 @@ export interface MakeInput<Body, Frame, Event, State> {
}
export interface MakeTransportInput<Body, Prepared, Frame, Event, State> {
readonly compact?: CompactOperation
/** Route id used in diagnostics and prepared request metadata. */
readonly id: string
/** Provider identity for route-owned model construction. */
@@ -312,14 +283,12 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
const build = (routeInput: BuiltRouteInput): Route<Body, Prepared> => {
const route: Route<Body, Prepared> = {
compact: routeInput.compact,
id: routeInput.id,
provider: routeInput.provider === undefined ? undefined : ProviderID.make(routeInput.provider),
providerMetadataKey: routeInput.providerMetadataKey,
protocol: protocol.id,
endpoint: routeInput.endpoint,
auth: routeInput.auth ?? Auth.none,
headers: routeInput.headers,
transport: routeInput.transport,
defaults: routeInput.defaults ?? {},
body: protocol.body,
@@ -341,7 +310,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
})
},
model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedLanguageModelInput) =>
makeRouteLanguageModel<Options, CompactOperation | undefined>(route, input),
makeRouteLanguageModel<Options>(route, input),
prepareTransport: (body, request, options) =>
routeInput.transport.prepare({
body,
@@ -349,6 +318,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
endpoint: routeInput.endpoint,
auth: routeInput.auth ?? Auth.none,
encodeBody,
headers: routeInput.headers,
middleware: options?.http,
webSocket: options?.webSocket,
}),
@@ -438,12 +408,6 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
return build({ ...input, defaults: mergeRouteDefaults(undefined, input.defaults ?? {}) })
}
export function make<Body, Prepared, Frame, Event, State>(
input: MakeTransportInput<Body, Prepared, Frame, Event, State> & { readonly compact: CompactOperation },
): Route<Body, Prepared, CompactOperation>
export function make<Body, Frame, Event, State>(
input: MakeInput<Body, Frame, Event, State> & { readonly compact: CompactOperation },
): Route<Body, HttpTransport.HttpPrepared<Frame>, CompactOperation>
export function make<Body, Prepared, Frame, Event, State>(
input: MakeTransportInput<Body, Prepared, Frame, Event, State>,
): Route<Body, Prepared>
@@ -471,7 +435,6 @@ export function make<Body, Prepared, Frame, Event, State>(
if ("transport" in input) return makeFromTransport(input)
const protocol = input.protocol
return makeFromTransport({
compact: input.compact,
id: input.id,
provider: input.provider,
providerMetadataKey: input.providerMetadataKey,
@@ -484,19 +447,11 @@ export function make<Body, Prepared, Frame, Event, State>(
})
}
const prepareRequest = (request: LLMRequest) => {
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest, options?: StreamOptions) {
const original = applyCachePolicy(resolveRequestOptions(request))
const sanitized = LLMRequest.update(original, sanitizeSurrogates({ ...LLMRequest.input(original), model: undefined }))
const tools = [...new Map(sanitized.tools.map((tool) => [tool.name, tool])).values()]
const resolved = tools.length === sanitized.tools.length ? sanitized : LLMRequest.update(sanitized, { tools })
const headers = resolved.model.route.headers?.({ request: resolved })
return headers === undefined
? resolved
: LLMRequest.update(resolved, { http: mergeHttpOptions(new HttpOptions({ headers }), resolved.http) })
}
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest, options?: StreamOptions) {
const resolved = prepareRequest(request)
const route = resolved.model.route
const body = yield* route.body
@@ -555,15 +510,6 @@ export function generate(request: LLMRequest, options?: StreamOptions): Effect.E
})
}
export const compact = (
request: CompactionRequest,
options?: Pick<StreamOptions, "http">,
): Effect.Effect<CompactionResponse, AIError, Service> =>
Effect.gen(function* () {
const client = yield* Service
return yield* client.compact(request, options)
})
export const streamRequest = (request: LLMRequest, options?: StreamOptions) =>
Stream.unwrap(
Effect.gen(function* () {
@@ -574,29 +520,16 @@ export const streamRequest = (request: LLMRequest, options?: StreamOptions) =>
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const stream = streamRequestWith({ http: executor })
return Service.of({
stream,
generate: generateWith(stream),
compact: (request, options) =>
Effect.suspend(() => {
const operation = request.model.route.compact
if (!operation)
return ProviderShared.invalidRequest(
`${request.model.provider}/${request.model.route.id} does not support explicit compaction`,
)
return operation(prepareRequest(request), executor, options)
}),
const stream = streamRequestWith({
http: yield* RequestExecutor.Service,
})
return Service.of({ stream, generate: generateWith(stream) })
}),
)
export const Route = { make } as const
export const LLMClient = {
canCompact,
compact,
Service,
layer,
stream,
+4 -18
View File
@@ -3,7 +3,6 @@ import { LLM } from "@opencode-ai/schema/llm"
import { ContentBlockID, ToolCallID } from "./ids.js"
import {
Message,
CompactionPart,
ProviderMetadata,
ToolCallPart,
ToolOutput,
@@ -63,8 +62,6 @@ export { ProviderMetadata } from "./messages.js"
* Matches the same escape-hatch field on `LLMEvent`.
*/
export class Usage extends Schema.Class<Usage>("AI.Usage")({
/** Effective input size of the final message iteration, when reported; not billed totals. */
contextTokens: Schema.optional(Schema.Number),
inputTokens: Schema.optional(Schema.Number),
outputTokens: Schema.optional(Schema.Number),
nonCachedInputTokens: Schema.optional(Schema.Number),
@@ -75,7 +72,7 @@ export class Usage extends Schema.Class<Usage>("AI.Usage")({
providerMetadata: Schema.optional(ProviderMetadata),
}) {
/**
* Non-reasoning output tokens (including compaction summaries) — `outputTokens` minus `reasoningTokens`, clamped
* Visible output tokens — `outputTokens` minus `reasoningTokens`, clamped
* to zero. The one place subtraction happens in this contract; the clamp
* means a provider reporting `reasoningTokens > outputTokens` produces a
* harmless zero rather than a negative that crashes downstream schemas.
@@ -91,12 +88,6 @@ export class Usage extends Schema.Class<Usage>("AI.Usage")({
export type UsageInput = Usage | ConstructorParameters<typeof Usage>[0]
/** A replacement context window. Replace prior history with these messages. */
export class CompactionResponse extends Schema.Class<CompactionResponse>("LLM.CompactionResponse")({
messages: Schema.Array(Message),
usage: Schema.optional(Usage),
}) {}
export const StepStart = Schema.Struct({
type: Schema.tag("step-start"),
index: Schema.Number,
@@ -250,7 +241,6 @@ export const ProviderErrorEvent = Schema.Struct({
export type ProviderErrorEvent = Schema.Schema.Type<typeof ProviderErrorEvent>
const llmEventTagged = Schema.Union([
CompactionPart,
StepStart,
TextStart,
TextDelta,
@@ -284,7 +274,6 @@ const toolCallID = (value: ToolCallID | string) => ToolCallID.make(value)
* `events.filter(LLMEvent.guards["tool-call"])`.
*/
export const LLMEvent = Object.assign(llmEventTagged, {
compaction: CompactionPart.make,
stepStart: StepStart.make,
textStart: (input: WithID<TextStart, ContentBlockID>) => TextStart.make({ ...input, id: contentBlockID(input.id) }),
textDelta: (input: WithID<TextDelta, ContentBlockID>) => TextDelta.make({ ...input, id: contentBlockID(input.id) }),
@@ -322,7 +311,6 @@ export const LLMEvent = Object.assign(llmEventTagged, {
}),
providerError: ProviderErrorEvent.make,
is: {
compaction: llmEventTagged.guards.compaction,
stepStart: llmEventTagged.guards["step-start"],
textStart: llmEventTagged.guards["text-start"],
textDelta: llmEventTagged.guards["text-delta"],
@@ -345,10 +333,10 @@ export const LLMEvent = Object.assign(llmEventTagged, {
export type LLMEvent = Schema.Schema.Type<typeof llmEventTagged>
/** Joins deltas per fragment, letting an authoritative end value replace that fragment's accumulated deltas. */
const joinFragments = (
const joinFragments = <Delta extends { id: string; text: string }, End extends { id: string; text?: string }>(
events: ReadonlyArray<LLMEvent>,
isDelta: (event: LLMEvent) => event is LLMEvent & { id: string; text: string },
isEnd: (event: LLMEvent) => event is LLMEvent & { id: string; text?: string },
isDelta: (event: LLMEvent) => event is Extract<LLMEvent, Delta>,
isEnd: (event: LLMEvent) => event is Extract<LLMEvent, End>,
) => {
const order: string[] = []
const parts = new Map<string, string>()
@@ -575,8 +563,6 @@ const reduceToolCall = (state: ResponseState, event: ToolCall): ResponseState =>
const reduceResponseState = (state: ResponseState, event: LLMEvent): ResponseState => {
const next = appendEvent(state, event)
switch (event.type) {
case "compaction":
return appendContent(next, event)
case "text-start":
return ensureText(next, event.id, event.providerMetadata)
case "text-delta":
+8 -66
View File
@@ -7,10 +7,8 @@ import {
HttpOptions,
JsonSchema,
LanguageModelSchema,
type LanguageModel,
ProviderOptions,
} from "./options.js"
import { ProviderID } from "./ids.js"
export const MessageRole = Schema.Literals(["system", "user", "assistant", "tool"])
export type MessageRole = Schema.Schema.Type<typeof MessageRole>
@@ -54,7 +52,6 @@ export const MediaPart = Schema.Struct({
filename: Schema.optional(Schema.String),
cache: Schema.optional(CacheHint),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Content.Media" })
export type MediaPart = Schema.Schema.Type<typeof MediaPart>
@@ -188,40 +185,9 @@ export const ReasoningPart = Schema.Struct({
}).annotate({ identifier: "LLM.Content.Reasoning" })
export type ReasoningPart = Schema.Schema.Type<typeof ReasoningPart>
/** A provider-generated context checkpoint, distinct from visible assistant text. */
type CompactionContent =
| { readonly encrypted: string; readonly text?: never }
| { readonly text: string | null; readonly encrypted?: never }
const compactionPartSchema = Schema.Struct({
type: Schema.Literal("compaction"),
provider: ProviderID,
id: Schema.optional(Schema.String),
encrypted: Schema.optional(Schema.String),
/** Null means the provider failed to produce a summary; prior history must be retained. */
text: Schema.optional(Schema.NullOr(Schema.String)),
})
.pipe(
Schema.refine(
(part): part is typeof part & CompactionContent => (part.encrypted !== undefined) !== (part.text !== undefined),
{ message: "Compaction requires either encrypted content or a summary" },
),
)
.annotate({ identifier: "LLM.Content.Compaction" })
export type CompactionPart = typeof compactionPartSchema.Type
export const CompactionPart = Object.assign(compactionPartSchema, {
make: (input: Omit<CompactionPart, "type" | "encrypted" | "text"> & CompactionContent): CompactionPart =>
Schema.decodeUnknownSync(compactionPartSchema)({ type: "compaction", ...input }),
})
export const ContentPart = Schema.Union([
TextPart,
MediaPart,
ToolCallPart,
ToolResultPart,
ReasoningPart,
CompactionPart,
]).pipe(Schema.toTaggedUnion("type"))
export const ContentPart = Schema.Union([TextPart, MediaPart, ToolCallPart, ToolResultPart, ReasoningPart]).pipe(
Schema.toTaggedUnion("type"),
)
export type ContentPart = Schema.Schema.Type<typeof ContentPart>
export class Message extends Schema.Class<Message>("LLM.Message")({
@@ -229,7 +195,6 @@ export class Message extends Schema.Class<Message>("LLM.Message")({
role: MessageRole,
content: Schema.Array(ContentPart),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
providerMetadata: Schema.optional(ProviderMetadata),
native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}) {}
@@ -307,7 +272,7 @@ export namespace ToolChoice {
}
}
const requestSchema = Schema.Struct({
export class LLMRequest extends Schema.Class<LLMRequest>("LLM.Request")({
id: Schema.optional(Schema.String),
model: LanguageModelSchema,
system: Schema.Array(SystemPart),
@@ -321,26 +286,12 @@ const requestSchema = Schema.Struct({
// Stable cache affinity for protocols that support provider-managed prompt caching.
promptCacheKey: Schema.optional(Schema.String),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
})
export class LLMRequest<Model extends LanguageModel = LanguageModel> extends Schema.Class<LLMRequest>("LLM.Request")(
requestSchema.fields,
) {
declare readonly model: Model
// Preserve model inference instead of inheriting the schema's erased constructor signature.
// oxlint-disable-next-line no-useless-constructor
constructor(input: LLMRequest.Input<Model>) {
super(input)
}
}
}) {}
export namespace LLMRequest {
export type Input<Model extends LanguageModel = LanguageModel> = Omit<typeof requestSchema.Type, "model"> & {
readonly model: Model
}
export type Input = ConstructorParameters<typeof LLMRequest>[0]
export const input = <Model extends LanguageModel>(request: LLMRequest<Model>): Input<Model> => ({
export const input = (request: LLMRequest): Input => ({
id: request.id,
model: request.model,
system: request.system,
@@ -355,16 +306,7 @@ export namespace LLMRequest {
metadata: request.metadata,
})
export function update<Model extends LanguageModel>(
request: LLMRequest,
patch: Partial<Input<Model>> & { readonly model: Model },
): LLMRequest<Model>
export function update<Model extends LanguageModel>(
request: LLMRequest<Model>,
patch: Partial<Omit<Input, "model">> & { readonly model?: undefined },
): LLMRequest<Model>
export function update(request: LLMRequest, patch: Partial<Input>): LLMRequest
export function update(request: LLMRequest, patch: Partial<Input>) {
export const update = (request: LLMRequest, patch: Partial<Input>) => {
if (Object.keys(patch).length === 0) return request
return new LLMRequest({
...input(request),
+10 -34
View File
@@ -1,6 +1,6 @@
import { Schema } from "effect"
import { ModelID, ProviderID } from "./ids.js"
import type { AnyRoute, CompactOperation } from "../route/client.js"
import type { AnyRoute } from "../route/client.js"
import { isRecord } from "../utils/record.js"
export const JsonSchema = Schema.Record(Schema.String, Schema.Unknown)
@@ -173,18 +173,15 @@ export namespace LanguageModelCompatibility {
input instanceof LanguageModelCompatibility ? input : new LanguageModelCompatibility(input)
}
export class LanguageModel<
Options extends ProviderOptions = ProviderOptions,
Compact extends CompactOperation | undefined = CompactOperation | undefined,
> {
export class LanguageModel<Options extends ProviderOptions = ProviderOptions> {
declare protected readonly _ProviderOptions: Options
readonly id: ModelID
readonly provider: ProviderID
readonly route: AnyRoute<Compact>
readonly route: AnyRoute
readonly defaults?: LanguageModelDefaults
readonly compatibility?: LanguageModelCompatibility
constructor(input: LanguageModel.ConstructorInput<Compact>) {
constructor(input: LanguageModel.ConstructorInput) {
this.id = input.id
this.provider = input.provider
this.route = input.route
@@ -192,11 +189,8 @@ export class LanguageModel<
this.compatibility = input.compatibility
}
static make<
Options extends ProviderOptions = ProviderOptions,
Compact extends CompactOperation | undefined = CompactOperation | undefined,
>(input: LanguageModel.Input<Compact>) {
return new LanguageModel<Options, Compact>({
static make<Options extends ProviderOptions = ProviderOptions>(input: LanguageModel.Input) {
return new LanguageModel<Options>({
id: ModelID.make(input.id),
provider: ProviderID.make(input.provider),
route: input.route,
@@ -206,9 +200,7 @@ export class LanguageModel<
})
}
static input<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
model: LanguageModel<Options, Compact>,
): LanguageModel.ConstructorInput<Compact> {
static input<Options extends ProviderOptions>(model: LanguageModel<Options>): LanguageModel.ConstructorInput {
return {
id: model.id,
provider: model.provider,
@@ -218,41 +210,25 @@ export class LanguageModel<
}
}
static update<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
model: LanguageModel<Options>,
patch: Partial<LanguageModel.Input<Compact>> & { readonly route: AnyRoute<Compact> },
): LanguageModel<Options, Compact>
static update<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
model: LanguageModel<Options, Compact>,
patch: Partial<Omit<LanguageModel.Input, "route">> & { readonly route?: undefined },
): LanguageModel<Options, Compact>
static update<Options extends ProviderOptions>(
model: LanguageModel<Options>,
patch: Partial<LanguageModel.Input>,
): LanguageModel<Options>
static update<Options extends ProviderOptions>(model: LanguageModel<Options>, patch: Partial<LanguageModel.Input>) {
if (Object.keys(patch).length === 0) return model
return LanguageModel.make<Options>({
...LanguageModel.input(model),
...patch,
route: patch.route ?? model.route,
})
}
}
export namespace LanguageModel {
export type ConstructorInput<Compact extends CompactOperation | undefined = CompactOperation | undefined> = {
export type ConstructorInput = {
readonly id: ModelID
readonly provider: ProviderID
readonly route: AnyRoute<Compact>
readonly route: AnyRoute
readonly defaults?: LanguageModelDefaults
readonly compatibility?: LanguageModelCompatibility
}
export type Input<Compact extends CompactOperation | undefined = CompactOperation | undefined> = Omit<
ConstructorInput<Compact>,
"id" | "provider" | "defaults" | "compatibility"
> & {
export type Input = Omit<ConstructorInput, "id" | "provider" | "defaults" | "compatibility"> & {
readonly id: string | ModelID
readonly provider: string | ProviderID
readonly defaults?: LanguageModelDefaults.Input
+11 -25
View File
@@ -4,7 +4,6 @@ import { LLMClient } from "./route/client.js"
import {
LLMEvent,
LLMResponse,
CompactionResponse,
type FinishReasonDetails,
type AIError,
type LLMRequest,
@@ -13,7 +12,7 @@ import {
} from "./schema/index.js"
import { Context, Deferred, Effect, Latch, Layer, Queue, Scope, Stream } from "effect"
export type Response = readonly LLMEvent[] | Stream.Stream<LLMEvent, AIError> | CompactionResponse
export type Response = readonly LLMEvent[] | Stream.Stream<LLMEvent, AIError>
export type Gate = Readonly<{ started: Effect.Effect<void>; release: Effect.Effect<void> }>
@@ -100,6 +99,8 @@ export const failAfter = (error: AIError, ...events: readonly LLMEvent[]) =>
export const hangAfter = (...events: readonly LLMEvent[]) => Stream.concat(Stream.fromIterable(events), Stream.never)
const toStream = (response: Response) => (Stream.isStream(response) ? response : Stream.fromIterable(response))
const make = (options: LayerOptions) =>
Effect.sync(() => {
const requests: LLMRequest[] = []
@@ -112,41 +113,26 @@ const make = (options: LayerOptions) =>
requests.length >= count ? Effect.void : Deferred.await(started).pipe(Effect.andThen(wait(count))),
)
const take = (request: LLMRequest) =>
Effect.suspend(() => {
const stream: ClientInterface["stream"] = (request) =>
Stream.suspend(() => {
const count = requests.push(options.transformRequest?.(request) ?? request)
const waiting = started
started = Deferred.makeUnsafe()
const gate = activeGate
try {
const response = responses.shift() ?? (typeof fallback === "function" ? fallback(request) : fallback)
if (!response) return Effect.die(new Error(`TestLLM has no response for request ${count}`))
if (!gate) return Effect.succeed(response)
return Queue.offer(gate.started, undefined).pipe(Effect.andThen(gate.release.await), Effect.as(response))
if (!response) return Stream.die(new Error(`TestLLM has no response for request ${count}`))
const streamed = toStream(response)
if (!gate) return streamed
return Stream.unwrap(
Queue.offer(gate.started, undefined).pipe(Effect.andThen(gate.release.await), Effect.as(streamed)),
)
} finally {
// Waiters can resume synchronously; assign the reply and gate before notifying them.
Deferred.doneUnsafe(waiting, Effect.void)
}
})
const stream: ClientInterface["stream"] = (request) =>
Stream.unwrap(
take(request).pipe(
Effect.map((response) => {
if (response instanceof CompactionResponse)
return Stream.die("TestLLM generation requires an event response")
return Stream.isStream(response) ? response : Stream.fromIterable(response)
}),
),
)
const test = Test.of({
compact: (request) =>
take(request).pipe(
Effect.flatMap((response) =>
response instanceof CompactionResponse
? Effect.succeed(response)
: Effect.die("TestLLM compaction requires a CompactionResponse"),
),
),
stream,
generate: (request) =>
stream(request).pipe(
-1
View File
@@ -56,7 +56,6 @@ function normalizeToolMessage(message: Message, pending: Map<string, ToolCallPar
role: message.role,
content,
metadata: message.metadata,
providerMetadata: message.providerMetadata,
native: message.native,
})
}
-32
View File
@@ -126,38 +126,6 @@ describe("applyCachePolicy", () => {
}),
)
for (const fixture of [
{ name: "default", cache: undefined, control: { type: "ephemeral" } },
{ name: "auto", cache: "auto", control: { type: "ephemeral" } },
{
name: "explicit one-hour",
cache: { tools: true, system: true, messages: { tail: 1 }, ttlSeconds: 3600 },
control: { type: "ephemeral", ttl: "1h" },
},
{ name: "disabled", cache: "none", control: undefined },
] as const) {
it.effect(`Bedrock Messages respects ${fixture.name} caching`, () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: AmazonBedrock.configure({ apiKey: "test" }).messages("anthropic.claude-opus-4-6-v1"),
system: "Stable instructions",
tools: [
{ name: "lookup", description: "Look up a value", inputSchema: { type: "object", properties: {} } },
],
prompt: "hello",
cache: fixture.cache,
}),
)
expect(prepared.body).toMatchObject({
tools: [{ name: "lookup", cache_control: fixture.control }],
system: [{ type: "text", text: "Stable instructions", cache_control: fixture.control }],
messages: [{ role: "user", content: [{ type: "text", text: "hello", cache_control: fixture.control }] }],
})
}),
)
}
it.effect("'auto' is a no-op on Gemini (out-of-band caching protocol)", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
-97
View File
@@ -1,97 +0,0 @@
import { expect } from "bun:test"
import { Effect } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { LLM, LLMRequest, Message } from "../src/index.js"
import { LLMClient } from "../src/route/client.js"
import { OpenAI } from "../src/providers.js"
import { testEffect } from "./lib/effect.js"
import { runtimeLayer } from "./lib/http.js"
import { sseEvents } from "./lib/sse.js"
testEffect(runtimeLayer(FetchHttpClient.layer)).live("compaction and a tool loop work end to end over HTTP", () =>
Effect.gen(function* () {
const checkpoint = { type: "compaction", id: "cmp_local", encrypted_content: "opaque-local-state" }
const calls: string[] = []
const server = yield* Effect.acquireRelease(
Effect.sync(() =>
Bun.serve({
hostname: "127.0.0.1",
port: 0,
async fetch(request) {
const path = new URL(request.url).pathname
calls.push(path)
const body = await request.json()
expect(request.headers.get("authorization")).toBe("Bearer fixture")
if (path === "/v1/responses/compact") {
expect(body.stream).toBeUndefined()
return Response.json({
object: "response.compaction",
output: [checkpoint],
usage: { input_tokens: 100, output_tokens: 10, total_tokens: 110 },
})
}
expect(body.input[0]).toEqual(checkpoint)
expect(body.stream).toBe(true)
if (calls.length === 2)
return new Response(
sseEvents(
{
type: "response.output_item.done",
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "{}" },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
{ headers: { "content-type": "text/event-stream" } },
)
expect(body.input.at(-2)).toMatchObject({ type: "function_call", call_id: "call_1" })
expect(body.input.at(-1)).toEqual({ type: "function_call_output", call_id: "call_1", output: "42" })
const output = sseEvents(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "The answer is 42." },
{
type: "response.output_item.done",
item: { type: "message", id: "msg_1", content: [{ type: "output_text", text: "The answer is 42." }] },
},
{ type: "response.completed", response: { id: "resp_2" } },
)
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(output.slice(0, 37)))
controller.enqueue(new TextEncoder().encode(output.slice(37)))
controller.close()
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
},
}),
),
(server) => Effect.sync(() => server.stop(true)),
)
const model = OpenAI.configure({ apiKey: "fixture", baseURL: `http://127.0.0.1:${server.port}/v1` }).responses(
"fixture",
)
const request = LLM.request({
model,
prompt: "original",
tools: [{ name: "lookup", description: "Lookup a number", inputSchema: { type: "object", properties: {} } }],
})
const compacted = yield* LLMClient.compact(request)
const messages = [...compacted.messages, Message.user("Look up the answer")]
const first = yield* LLMClient.generate(LLMRequest.update(request, { messages }))
expect(first.toolCalls).toHaveLength(1)
const call = first.toolCalls[0]!
const last = yield* LLMClient.generate(
LLMRequest.update(request, {
messages: [
...messages,
first.message,
Message.tool({ id: call.id, name: call.name, result: "42", resultType: "text" }),
],
}),
)
expect(last.text).toBe("The answer is 42.")
expect(calls).toEqual(["/v1/responses/compact", "/v1/responses", "/v1/responses"])
}),
)
-64
View File
@@ -1,64 +0,0 @@
import { expect, test } from "bun:test"
import { Schema } from "effect"
import { CompactionPart, LLMEvent, LLMResponse, Message, ProviderID } from "../src/schema/index.js"
import { LLM, LLMClient, LLMRequest, LanguageModel } from "../src/index.js"
import { OpenAI, Anthropic } from "../src/providers.js"
test("runtime capability checks follow model and route updates", () => {
const supported = OpenAI.configure({ apiKey: "test" }).responses("fixture")
const unsupported = Anthropic.configure({ apiKey: "test" }).model("fixture")
const request = LLM.request({ model: supported, prompt: "hello" })
expect(LLMClient.canCompact(request)).toBe(true)
expect(LLMClient.canCompact(LLMRequest.update(request, { messages: [] }))).toBe(true)
expect(LLMClient.canCompact(LLMRequest.update(request, { model: unsupported }))).toBe(false)
expect(
LLMClient.canCompact(LLM.request({ model: LanguageModel.update(supported, { route: unsupported.route }) })),
).toBe(false)
expect(LLMClient.canCompact(LLM.request({ model: LanguageModel.update(supported, { route: undefined }) }))).toBe(true)
})
test("compaction survives event assembly and message serialization without becoming text", () => {
const part = CompactionPart.make({
provider: ProviderID.make("openai"),
id: "cmp_1",
encrypted: "opaque",
})
const response = LLMResponse.fromEvents([
LLMEvent.textStart({ id: "before" }),
LLMEvent.textDelta({ id: "before", text: "Before" }),
LLMEvent.textEnd({ id: "before" }),
part,
LLMEvent.textStart({ id: "after" }),
LLMEvent.textDelta({ id: "after", text: "After" }),
LLMEvent.textEnd({ id: "after" }),
LLMEvent.finish({ reason: { normalized: "stop" } }),
])!
expect(response.message.content.map((part) => part.type)).toEqual(["text", "compaction", "text"])
expect(response.text).toBe("BeforeAfter")
expect(response.reasoning).toBe("")
expect(response.events.filter(LLMEvent.is.compaction)).toEqual([part])
const codec = Schema.fromJsonString(Message)
expect(Schema.decodeSync(codec)(Schema.encodeSync(codec)(response.message))).toEqual(response.message)
})
test("compaction requires exactly one typed representation", () => {
const provider = ProviderID.make("anthropic")
expect(CompactionPart.make({ provider, text: null })).toEqual({ type: "compaction", provider, text: null })
const decode = Schema.decodeUnknownSync(CompactionPart)
expect(() => decode({ type: "compaction", provider })).toThrow()
expect(() => decode({ type: "compaction", provider, text: "summary", encrypted: "opaque" })).toThrow()
})
test("tagged content and event guards accept both checkpoint representations", () => {
for (const part of [
CompactionPart.make({ provider: ProviderID.make("openai"), encrypted: "opaque" }),
CompactionPart.make({ provider: ProviderID.make("anthropic"), text: "summary" }),
CompactionPart.make({ provider: ProviderID.make("anthropic"), text: null }),
]) {
expect(LLMEvent.is.compaction(part)).toBe(true)
expect(LLMEvent.guards.compaction(part)).toBe(true)
const codec = Schema.fromJsonString(Message)
const message = Message.assistant(part)
expect(Schema.decodeSync(codec)(Schema.encodeSync(codec)(message))).toEqual(message)
}
})
@@ -1,154 +0,0 @@
import { Effect } from "effect"
import {
CompactionPart,
LanguageModel,
LLM,
LLMClient,
LLMEvent,
LLMRequest,
Message,
ProviderID,
} from "../../src/index.js"
import {
OpenAI,
Azure,
XAI,
Anthropic,
AmazonBedrock,
AmazonBedrockMantle,
OpenAICompatibleResponses,
} from "../../src/providers.js"
const openai = OpenAI.configure({
apiKey: "test",
providerOptions: { contextManagement: [{ type: "compaction", compactThreshold: 100000 }] },
}).responses("gpt-5.3-codex")
LLMClient.compact(LLM.request({ model: openai, prompt: "hello" }))
for (const model of [
OpenAI.configure().responses("fixture"),
Azure.configure({ resourceName: "test" }).responses("fixture"),
XAI.configure().responses("fixture"),
OpenAI.model("fixture", {}),
Azure.responsesModel("fixture", { resourceName: "test" }),
XAI.model("fixture", {}),
openai.route.with({ headers: { "x-test": "test" } }).model({ id: "fixture" }),
LanguageModel.update(openai, { defaults: { generation: { maxTokens: 100 } } }),
LanguageModel.make(LanguageModel.input(openai)),
]) {
LLMClient.compact(LLM.request({ model, prompt: "hello" }))
}
const unsupported = {
anthropic: LLM.request({ model: Anthropic.configure().model("fixture") }),
openaiChat: LLM.request({ model: OpenAI.configure().chat("fixture") }),
azureChat: LLM.request({ model: Azure.configure({ resourceName: "test" }).chat("fixture") }),
xaiChat: LLM.request({ model: XAI.configure().chat("fixture") }),
converse: LLM.request({ model: AmazonBedrock.configure().model("fixture") }),
bedrockMessages: LLM.request({ model: AmazonBedrock.configure().messages("fixture") }),
mantle: LLM.request({ model: AmazonBedrockMantle.configure().responses("fixture") }),
compatible: LLM.request({
model: OpenAICompatibleResponses.configure({ baseURL: "https://example.com" }).model("fixture"),
}),
}
// @ts-expect-error Anthropic has no standalone compact endpoint.
LLMClient.compact(unsupported.anthropic)
// @ts-expect-error Chat does not expose Responses compaction.
LLMClient.compact(unsupported.openaiChat)
// @ts-expect-error Azure Chat does not expose Responses compaction.
LLMClient.compact(unsupported.azureChat)
// @ts-expect-error xAI Chat does not expose Responses compaction.
LLMClient.compact(unsupported.xaiChat)
// @ts-expect-error Converse has no standalone compact endpoint.
LLMClient.compact(unsupported.converse)
// @ts-expect-error Bedrock Messages has no standalone compact endpoint.
LLMClient.compact(unsupported.bedrockMessages)
// @ts-expect-error Mantle does not inherit the OpenAI compact endpoint.
LLMClient.compact(unsupported.mantle)
// @ts-expect-error Protocol compatibility does not guarantee endpoint support.
LLMClient.compact(unsupported.compatible)
LLMClient.Service.use((client) => {
// @ts-expect-error The service enforces the same capability as the convenience function.
return client.compact(unsupported.anthropic)
})
const request = LLM.request({ model: openai, prompt: "hello" })
LLMClient.compact(LLMRequest.update(request, { messages: [Message.user("continue")] }))
LLMClient.compact(new LLMRequest(LLMRequest.input(request)))
const switched = LLMRequest.update(request, { model: Anthropic.configure().model("fixture") })
// @ts-expect-error Switching models replaces, rather than inherits, the capability.
LLMClient.compact(switched)
LLMClient.compact(LLMRequest.update(switched, { model: openai }))
LLMClient.compact(
// @ts-expect-error Replacing the route also replaces compaction capability.
LLM.request({ model: LanguageModel.update(openai, { route: Anthropic.configure().model("fixture").route }) }),
)
declare const dynamicModel: LanguageModel
declare const dynamicPatch: Partial<LLMRequest.Input>
const dynamicRequest = LLM.request({ model: dynamicModel, prompt: "hello" })
// @ts-expect-error A dynamically selected model must be narrowed first.
LLMClient.compact(dynamicRequest)
if (LLMClient.canCompact(dynamicRequest)) LLMClient.compact(dynamicRequest)
// @ts-expect-error An optional model override cannot retain the old capability statically.
LLMClient.compact(LLMRequest.update(request, dynamicPatch))
const checkpoint = CompactionPart.make({ provider: ProviderID.make("openai"), id: "cmp_1", encrypted: "opaque" })
const provider = ProviderID.make("anthropic")
CompactionPart.make({ provider, text: "summary" })
CompactionPart.make({ provider, text: null })
// @ts-expect-error A checkpoint must have a representation.
CompactionPart.make({ provider })
// @ts-expect-error Encrypted and summary representations are mutually exclusive.
CompactionPart.make({ provider, encrypted: "opaque", text: "summary" })
// @ts-expect-error A failed summary cannot also carry encrypted content.
LLMEvent.compaction({ provider, encrypted: "opaque", text: null })
// @ts-expect-error The canonical message type also enforces the invariant.
Message.assistant({ type: "compaction", provider })
if (checkpoint.encrypted !== undefined) {
checkpoint.encrypted satisfies string
checkpoint.text satisfies undefined
}
if (checkpoint.text !== undefined) {
checkpoint.text satisfies string | null
checkpoint.encrypted satisfies undefined
}
checkpoint.encrypted
// @ts-expect-error Compaction parts do not contain a generic provider payload.
checkpoint.value
LLMClient.compact(LLM.request({ model: openai, prompt: "hello" })).pipe(
Effect.map((result) => {
result.messages
// @ts-expect-error Compaction returns replacement history, not a synthetic assistant message.
result.message
}),
)
LLM.request({
model: openai,
providerOptions: {
// @ts-expect-error A token threshold is numeric.
contextManagement: [{ type: "compaction", compactThreshold: "100000" }],
},
})
for (const model of [
Anthropic.configure().model("claude-opus-4-6"),
AmazonBedrock.configure().messages("anthropic.claude-opus-4-6-v1"),
]) {
LLM.request({
model,
providerOptions: {
contextManagement: {
edits: [
{ type: "compact_20260112", pauseAfterCompaction: true, instructions: "Summarize without using tools" },
],
},
},
})
LLM.request({
model,
providerOptions: {
// @ts-expect-error A pause setting is boolean.
contextManagement: { edits: [{ type: "compact_20260112", pauseAfterCompaction: "yes" }] },
},
})
}
@@ -1,170 +0,0 @@
import { expect } from "bun:test"
import { Effect, Schema } from "effect"
import { LLM, LLMRequest, Message } from "../../src/index.js"
import { LLMClient } from "../../src/route/client.js"
import { Anthropic, GoogleVertexMessages } from "../../src/providers/index.js"
import { testEffect } from "../lib/effect.js"
import { dynamicResponse, fixedResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
for (const fixture of [
{
name: "empty iterations fall back to top-level usage",
usage: { input_tokens: 2, output_tokens: 3, cache_read_input_tokens: null, iterations: [] },
expected: { inputTokens: 2, outputTokens: 3, totalTokens: 5, contextTokens: undefined },
},
{
name: "compaction-only usage has no post-compaction context size",
usage: {
input_tokens: 0,
output_tokens: 0,
iterations: [{ type: "compaction", input_tokens: 7, cache_read_input_tokens: 3, output_tokens: 2 }],
},
expected: { inputTokens: 10, outputTokens: 2, totalTokens: 12, contextTokens: undefined },
},
{
name: "partially reported iterations preserve known totals",
usage: {
iterations: [
{ type: "compaction", input_tokens: 7, cache_creation_input_tokens: 2 },
{ type: "message", output_tokens: 3 },
],
},
expected: { inputTokens: 9, outputTokens: 3, totalTokens: 12, contextTokens: undefined },
},
{
name: "missing counters remain unknown rather than zero",
usage: { iterations: [{ type: "message" }] },
expected: { inputTokens: undefined, outputTokens: undefined, totalTokens: undefined, contextTokens: undefined },
},
]) {
testEffect(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: fixture.usage } },
{ type: "message_delta", delta: { stop_reason: "end_turn" } },
{ type: "message_stop" },
),
),
).effect(fixture.name, () =>
Effect.gen(function* () {
const result = yield* LLMClient.generate(
LLM.request({
model: Anthropic.configure({ apiKey: "test" }).model("claude-opus-4-6"),
prompt: "hello",
}),
)
expect(result.usage).toMatchObject(fixture.expected)
}),
)
}
for (const model of [
Anthropic.configure({ apiKey: "test" }).model("claude-opus-4-6"),
GoogleVertexMessages.configure({ accessToken: "test", project: "test" }).model("claude-opus-4-6"),
]) {
for (const summary of ["Summary of the conversation", null]) {
const block = { type: "compaction", content: summary }
testEffect(
dynamicResponse(({ request, text, respond }) =>
Effect.sync(() => {
const body = JSON.parse(text)
expect(request.headers["anthropic-beta"]).toBe("existing-beta,compact-2026-01-12")
if (body.messages.length === 1) {
expect(body.context_management.edits).toEqual([
{
type: "compact_20260112",
trigger: { type: "input_tokens", value: 50000 },
pause_after_compaction: true,
},
])
}
if (body.messages.length > 1) {
expect(body.messages[1].content).toEqual([block])
expect(body.context_management).toBeUndefined()
}
return respond(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 50000, output_tokens: 0 } } },
{ type: "content_block_start", index: 0, content_block: { type: "compaction", content: null } },
{ type: "content_block_delta", index: 0, delta: { type: "compaction_delta", content: summary } },
{ type: "content_block_stop", index: 0 },
{
type: "message_delta",
delta: { stop_reason: "compaction" },
usage: {
input_tokens: 1000,
output_tokens: 5,
iterations: [
{ type: "compaction", input_tokens: 50000, output_tokens: 1000, cache_read_input_tokens: 10 },
{ type: "message", input_tokens: 1000, output_tokens: 5 },
],
},
},
{ type: "message_stop" },
),
{ headers: { "content-type": "text/event-stream" } },
)
}),
),
).effect(
`${model.provider} replays ${summary === null ? "failed" : "successful"} compaction with billing and context usage`,
() =>
Effect.gen(function* () {
const request = LLM.request({
model,
prompt: "hello",
http: { headers: { "anthropic-beta": "existing-beta" } },
providerOptions: {
contextManagement: {
edits: [
{
type: "compact_20260112",
trigger: { type: "input_tokens", value: 50000 },
pauseAfterCompaction: true,
},
],
},
},
})
const first = yield* LLMClient.generate(request)
expect(first.finishReason.raw).toBe("compaction")
expect(first.message.content).toEqual([{ type: "compaction", provider: model.provider, text: summary }])
expect(first.text).toBe("")
expect(first.usage?.inputTokens).toBe(51010)
expect(first.usage?.outputTokens).toBe(1005)
expect(first.usage?.totalTokens).toBe(52015)
expect(first.usage?.contextTokens).toBe(1000)
const codec = Schema.fromJsonString(Message)
const message = Schema.decodeSync(codec)(Schema.encodeSync(codec)(first.message))
yield* LLMClient.generate(
LLMRequest.update(request, {
providerOptions: {},
messages: [...request.messages, message, Message.user("continue")],
}),
)
}),
)
}
}
for (const events of [
[{ type: "content_block_start", index: 0, content_block: { type: "compaction", content: 42 } }],
[{ type: "content_block_delta", index: 0, delta: { type: "compaction_delta", content: "no start" } }],
[
{ type: "content_block_start", index: 0, content_block: { type: "compaction", content: null } },
{ type: "message_stop" },
],
]) {
testEffect(fixedResponse(sseEvents(...events))).effect(
`rejects malformed compaction lifecycle: ${JSON.stringify(events)}`,
() =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(
LLM.request({ model: Anthropic.configure({ apiKey: "test" }).model("claude-opus-4-6"), prompt: "hello" }),
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.reason.http?.status).toBe(200)
}),
)
}
@@ -1,69 +0,0 @@
import { EventStreamCodec } from "@smithy/eventstream-codec"
import { fromUtf8, toUtf8 } from "@smithy/util-utf8"
import { expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMRequest, Message } from "../../src/index.js"
import { LLMClient } from "../../src/route/client.js"
import { AmazonBedrock } from "../../src/providers/index.js"
import { testEffect } from "../lib/effect.js"
import { dynamicResponse } from "../lib/http.js"
const codec = new EventStreamCodec(toUtf8, fromUtf8)
const frame = (event: object) =>
codec.encode({
headers: { ":message-type": { type: "string", value: "event" }, ":event-type": { type: "string", value: "chunk" } },
body: new TextEncoder().encode(JSON.stringify({ bytes: Buffer.from(JSON.stringify(event)).toString("base64") })),
})
const response = Buffer.concat(
[
{ type: "message_start", message: { usage: { input_tokens: 60000 } } },
{ type: "content_block_start", index: 0, content_block: { type: "compaction", content: null } },
{ type: "content_block_delta", index: 0, delta: { type: "compaction_delta", content: "Summary" } },
{ type: "content_block_stop", index: 0 },
{ type: "content_block_start", index: 1, content_block: { type: "text", text: "" } },
{ type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "Hello" } },
{ type: "content_block_stop", index: 1 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 10 } },
{ type: "message_stop" },
].map(frame),
)
for (const auth of [
{ apiKey: "test" },
{ credentials: { accessKeyId: "test", secretAccessKey: "test", region: "us-west-2" } },
]) {
testEffect(
dynamicResponse(({ request, text, respond }) =>
Effect.sync(() => {
expect(request.url).toBe(
"https://bedrock-runtime.us-west-2.amazonaws.com/model/anthropic.claude-opus-4-6-v1%3A0/invoke-with-response-stream",
)
expect(request.headers.authorization).toStartWith(auth.apiKey ? "Bearer test" : "AWS4-HMAC-SHA256")
const body = JSON.parse(text)
expect(body.model).toBeUndefined()
expect(body.stream).toBeUndefined()
expect(body.anthropic_version).toBe("bedrock-2023-05-31")
expect(body.anthropic_beta).toEqual(["compact-2026-01-12"])
expect(body.context_management.edits).toEqual([{ type: "compact_20260112" }])
if (body.messages.length > 1)
expect(body.messages[1].content[0]).toEqual({ type: "compaction", content: "Summary" })
return respond(response, { headers: { "content-type": "application/vnd.amazon.eventstream" } })
}),
),
).effect(`Bedrock Messages compaction round trip with ${auth.apiKey ? "bearer" : "SigV4"} authentication`, () =>
Effect.gen(function* () {
const model = AmazonBedrock.configure({ ...auth, region: "us-west-2" }).messages("anthropic.claude-opus-4-6-v1:0")
const request = LLM.request({
model,
prompt: "hello",
providerOptions: { contextManagement: { edits: [{ type: "compact_20260112" }] } },
})
const first = yield* LLMClient.generate(request)
expect(first.text).toBe("Hello")
expect(first.message.content.map((part) => part.type)).toEqual(["compaction", "text"])
yield* LLMClient.generate(
LLMRequest.update(request, { messages: [...request.messages, first.message, Message.user("continue")] }),
)
}),
)
}
@@ -631,10 +631,45 @@ describe("Bedrock Converse route", () => {
)
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, totalTokens: 7 })
}),
)
it.effect("retains metadata usage that arrives before messageStop", () =>
Effect.gen(function* () {
const body = eventStreamBody(
["metadata", { usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 } }],
["messageStop", { stopReason: "end_turn" }],
)
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, totalTokens: 7 })
}),
)
it.effect("rejects metadata-only streams as incomplete with HTTP context", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(baseRequest).pipe(
Effect.provide(
fixedBytes(eventStreamBody(["metadata", { usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 } }])),
),
Effect.flip,
)
expect(error.reason).toMatchObject({
_tag: "InvalidProviderOutput",
classification: "incomplete-stream",
http: {
status: 200,
headers: { "content-type": "application/vnd.amazon.eventstream" },
},
})
}),
)
it.effect("assembles streamed tool call input", () =>
Effect.gen(function* () {
const body = eventStreamBody(
@@ -1,166 +0,0 @@
import { EventStreamCodec } from "@smithy/eventstream-codec"
import { fromUtf8, toUtf8 } from "@smithy/util-utf8"
import { expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMRequest, Message } from "../../src/index.js"
import { LLMClient, compileRequest } from "../../src/route/client.js"
import { AmazonBedrock } from "../../src/providers/index.js"
import { it, testEffect } from "../lib/effect.js"
import { dynamicResponse, fixedResponse } from "../lib/http.js"
const codec = new EventStreamCodec(toUtf8, fromUtf8)
const response = Buffer.concat(
[
{ type: "message_start", message: { usage: { input_tokens: 10 } } },
{ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hello" } },
{ type: "content_block_stop", index: 0 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 2 } },
{ type: "message_stop" },
].map((event) =>
codec.encode({
headers: {
":message-type": { type: "string", value: "event" },
":event-type": { type: "string", value: "chunk" },
},
body: new TextEncoder().encode(JSON.stringify({ bytes: Buffer.from(JSON.stringify(event)).toString("base64") })),
}),
),
)
for (const auth of [
{ apiKey: "test" },
{ credentials: { accessKeyId: "test", secretAccessKey: "test", region: "us-west-2" } },
]) {
testEffect(
dynamicResponse(({ request, text, respond }) =>
Effect.sync(() => {
expect(request.url).toBe(
"https://bedrock-runtime.us-west-2.amazonaws.com/model/anthropic.claude-opus-4-6-v1%3A0/invoke-with-response-stream",
)
expect(request.headers.authorization).toStartWith(auth.apiKey ? "Bearer test" : "AWS4-HMAC-SHA256")
const body = JSON.parse(text)
expect(body.model).toBeUndefined()
expect(body.stream).toBeUndefined()
expect(body.anthropic_version).toBe("bedrock-2023-05-31")
expect(body.anthropic_beta).toEqual(["existing-beta"])
if (body.messages.length > 1) expect(body.messages[1].content).toEqual([{ type: "text", text: "Hello" }])
return respond(response, { headers: { "content-type": "application/vnd.amazon.eventstream" } })
}),
),
).effect(`Bedrock Messages text round trip with ${auth.apiKey ? "bearer" : "SigV4"} authentication`, () =>
Effect.gen(function* () {
const provider = AmazonBedrock.configure({ ...auth, region: "us-west-2" })
expect(provider.model("fixture").route.id).toBe("bedrock-converse")
const request = LLM.request({
model: provider.messages("anthropic.claude-opus-4-6-v1:0"),
prompt: "hello",
http: { headers: { "anthropic-beta": "existing-beta, existing-beta" } },
})
const first = yield* LLMClient.generate(request)
expect(first.text).toBe("Hello")
expect(first.usage?.totalTokens).toBe(12)
yield* LLMClient.generate(
LLMRequest.update(request, {
messages: [...request.messages, first.message, Message.user("continue")],
}),
)
}),
)
}
testEffect(
fixedResponse(
codec.encode({
headers: {
":message-type": { type: "string", value: "exception" },
":exception-type": { type: "string", value: "throttlingException" },
},
body: new TextEncoder().encode(JSON.stringify({ message: "Too many requests", trace: "keep-original" })),
}),
),
).effect("Bedrock Messages retains the original exception frame", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(
LLM.request({
model: AmazonBedrock.configure({ apiKey: "test" }).messages("claude"),
prompt: "hello",
}),
).pipe(Effect.flip)
expect(error.reason.body).toContain("keep-original")
expect(error.reason.http?.status).toBe(200)
}),
)
for (const mediaType of ["image/png", "application/pdf"]) {
for (const data of ["https://example.com/media", "invalid base64!"]) {
for (const role of ["user", "tool"] as const) {
it.effect(`rejects ${role} ${mediaType} with ${data}`, () =>
Effect.gen(function* () {
const error = yield* compileRequest(
LLM.request({
model: AmazonBedrock.configure({ apiKey: "test" }).messages("claude"),
messages:
role === "user"
? [Message.user({ type: "media", mediaType, data })]
: [
Message.tool({
id: "call_1",
name: "read",
result: { type: "content", value: [{ type: "file", mime: mediaType, uri: data }] },
}),
],
}),
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
expect(error.message).toContain("Bedrock Messages")
}),
)
}
}
}
it.effect("accepts inline image and document sources", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: AmazonBedrock.configure({ apiKey: "test" }).messages("claude"),
messages: [
Message.user([
{ type: "media", mediaType: "image/png", data: "data:image/png;base64,AQID" },
{ type: "media", mediaType: "application/pdf", data: "data:application/pdf;base64,AQID" },
]),
],
}),
)
expect(prepared.body.messages[0].content.map((block: { source: { type: string } }) => block.source.type)).toEqual([
"base64",
"base64",
])
}),
)
it.effect("rejects Anthropic file IDs", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
LLM.request({
model: AmazonBedrock.configure({ apiKey: "test" }).messages("claude"),
messages: [Message.user({ type: "media", mediaType: "image/png", data: "", metadata: { file_id: "file_1" } })],
}),
).pipe(Effect.flip)
expect(error.message).toContain("file-ID")
}),
)
it.effect("rejects unsupported image formats", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
LLM.request({
model: AmazonBedrock.configure({ apiKey: "test" }).messages("claude"),
messages: [Message.user({ type: "media", mediaType: "image/svg+xml", data: "AQID" })],
}),
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
expect(error.message).toContain("JPEG, PNG, WebP, or GIF")
}),
)
@@ -1,50 +0,0 @@
import { expect } from "bun:test"
import { Effect, Stream } from "effect"
import { LLM, LLMRequest, Message } from "../../src/index.js"
import { LLMClient, WebSocketTransport } from "../../src/route.js"
import { OpenAI } from "../../src/providers.js"
import { testEffect } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
testEffect(fixedResponse("unexpected HTTP fallback")).effect(
"WebSocket responses preserve compaction options and replay state",
() =>
Effect.gen(function* () {
const checkpoint = { type: "compaction", id: "cmp_ws", encrypted_content: "opaque" }
const sent: unknown[] = []
const webSocket = WebSocketTransport.makeDirect({
open: () =>
Effect.succeed({
sendText: (message) =>
Effect.sync(() => {
const body = JSON.parse(message)
expect(body.context_management).toEqual([{ type: "compaction", compact_threshold: 100000 }])
expect(body.stream).toBeUndefined()
if (sent.length) expect(body.input[1]).toEqual(checkpoint)
sent.push(body)
}),
messages: Stream.fromIterable(
[
{ type: "response.created", response: { id: "resp_ws" } },
{ type: "response.output_item.done", item: checkpoint },
{ type: "response.completed", response: { id: "resp_ws", output: [checkpoint] } },
].map((event) => JSON.stringify(event)),
),
close: Effect.void,
}),
})
const request = LLM.request({
model: OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"),
prompt: "hello",
providerOptions: { contextManagement: [{ type: "compaction", compactThreshold: 100000 }] },
})
const first = yield* LLMClient.generate(request, { webSocket })
expect(first.message.content).toHaveLength(1)
expect(first.message.content[0]?.type).toBe("compaction")
yield* LLMClient.generate(
LLMRequest.update(request, { messages: [...request.messages, first.message, Message.user("continue")] }),
{ webSocket },
)
expect(sent).toHaveLength(2)
}),
)
@@ -1,91 +0,0 @@
import { expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMRequest, Message } from "../../src/index.js"
import { LLMClient } from "../../src/route/client.js"
import { OpenAI, XAI, Anthropic } from "../../src/providers.js"
import { recordedTests } from "../recorded-test.js"
const history = [
Message.user("Remember the project codename COPPER-ORBIT-42."),
Message.assistant(
"The project codename is COPPER-ORBIT-42. " + "We reviewed the implementation and tests. ".repeat(1000),
),
]
for (const provider of [
{
id: "openai",
key: "OPENAI_API_KEY",
model: OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY ?? "fixture" }).responses("gpt-5.3-codex"),
},
{
id: "xai",
key: "XAI_API_KEY",
model: XAI.configure({ apiKey: process.env.XAI_API_KEY ?? "fixture" }).responses("grok-4.6"),
},
]) {
recordedTests({ prefix: `${provider.id}-compaction`, provider: provider.id, requires: [provider.key] }).effect(
"compacts and continues with the provider checkpoint",
() =>
Effect.gen(function* () {
const request = LLM.request({ model: provider.model, messages: history, generation: { maxTokens: 1024 } })
const compacted = yield* LLMClient.compact(request)
const result = yield* LLMClient.generate(
LLMRequest.update(request, {
messages: [
...compacted.messages,
Message.user("What is the project codename? Reply only with the codename."),
],
}),
)
expect(result.text).toContain("COPPER-ORBIT-42")
}),
120000,
)
}
recordedTests({
prefix: "anthropic-compaction",
provider: "anthropic",
requires: ["ANTHROPIC_API_KEY"],
options: { redact: { allowRequestHeaders: ["anthropic-version", "anthropic-beta"] } },
}).effect(
"automatically compacts and continues after a pause",
() =>
Effect.gen(function* () {
const model = Anthropic.configure({ apiKey: process.env.ANTHROPIC_API_KEY ?? "fixture" }).model(
"claude-sonnet-4-6",
)
const request = LLM.request({
model,
messages: [
Message.user(
"Remember the project codename COPPER-ORBIT-42. " +
"The implementation and tests were reviewed. ".repeat(10000),
),
],
generation: { maxTokens: 4096 },
providerOptions: {
contextManagement: {
edits: [
{ type: "compact_20260112", trigger: { type: "input_tokens", value: 50000 }, pauseAfterCompaction: true },
],
},
},
})
const first = yield* LLMClient.generate(request)
expect(first.finishReason.raw).toBe("compaction")
expect(first.message.content.some((part) => part.type === "compaction")).toBe(true)
const result = yield* LLMClient.generate(
LLMRequest.update(request, {
messages: [
...request.messages,
first.message,
Message.user("What is the project codename? Reply only with the codename."),
],
}),
)
expect(result.text).toContain("COPPER-ORBIT-42")
}),
120000,
)
@@ -1,136 +0,0 @@
import { expect } from "bun:test"
import { Effect, Schema } from "effect"
import { LLM, LLMRequest, Message } from "../../src/index.js"
import { LLMClient } from "../../src/route/client.js"
import { OpenAI, Azure, XAI } from "../../src/providers/index.js"
import { testEffect } from "../lib/effect.js"
import { dynamicResponse, fixedResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
const checkpoint = { type: "compaction", id: "cmp_1", encrypted_content: "opaque" }
const response = sseEvents(
{ type: "response.output_item.done", item: checkpoint },
{
type: "response.completed",
response: { id: "resp_1", output: [checkpoint], usage: { input_tokens: 10, output_tokens: 2, total_tokens: 12 } },
},
)
for (const model of [
OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"),
Azure.configure({ apiKey: "test", resourceName: "test" }).responses("deployment"),
]) {
testEffect(
dynamicResponse(({ text, respond }) =>
Effect.sync(() => {
const body = JSON.parse(text)
expect(body.context_management).toEqual([{ type: "compaction", compact_threshold: 100000 }])
expect(body.store).toBe(false)
if (body.input.length > 1) expect(body.input[1]).toEqual(checkpoint)
return respond(response, { headers: { "content-type": "text/event-stream" } })
}),
),
).effect(`${model.provider} compaction survives generation, serialization, and a second request`, () =>
Effect.gen(function* () {
const request = LLM.request({
model,
prompt: "hello",
providerOptions: { contextManagement: [{ type: "compaction", compactThreshold: 100000 }] },
})
const first = yield* LLMClient.generate(request)
expect(first.message.content).toHaveLength(1)
expect(first.message.content[0]?.type).toBe("compaction")
expect(first.text).toBe("")
const codec = Schema.fromJsonString(Message)
const message = Schema.decodeSync(codec)(Schema.encodeSync(codec)(first.message))
yield* LLMClient.generate(
LLMRequest.update(request, { messages: [...request.messages, message, Message.user("continue")] }),
)
const rejected = yield* LLMClient.generate(
LLMRequest.update(request, {
model: XAI.configure({ apiKey: "test" }).responses("grok-4.6"),
providerOptions: {},
messages: [message],
}),
).pipe(Effect.flip)
expect(rejected.reason._tag).toBe("InvalidRequest")
}),
)
}
testEffect(fixedResponse(sseEvents({ type: "response.completed", response: { output: [checkpoint] } }))).effect(
"recovers compaction from the terminal output when item completion is absent",
() =>
Effect.gen(function* () {
const result = yield* LLMClient.generate(
LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"), prompt: "hello" }),
)
expect(result.message.content).toHaveLength(1)
expect(result.message.content[0]?.type).toBe("compaction")
}),
)
testEffect(
fixedResponse(sseEvents({ type: "response.output_item.done", item: { type: "compaction", id: "cmp_bad" } })),
).effect("rejects incomplete compaction payloads without publishing a checkpoint", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(
LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"), prompt: "hello" }),
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.reason.body).toContain("cmp_bad")
}),
)
const textItem = {
type: "message",
id: "msg_after",
role: "assistant",
content: [{ type: "output_text", text: "After checkpoint" }],
}
for (const completed of [false, true]) {
testEffect(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 0, item: { type: "compaction", id: checkpoint.id } },
...(completed ? [{ type: "response.output_item.done", output_index: 0, item: checkpoint }] : []),
{ type: "response.output_item.added", output_index: 1, item: textItem },
{ type: "response.output_text.delta", output_index: 1, item_id: textItem.id, delta: "After checkpoint" },
{ type: "response.output_item.done", output_index: 1, item: textItem },
{ type: "response.completed", response: { id: "resp_1", output: [checkpoint, textItem] } },
),
),
).effect(completed ? "keeps streamed checkpoints before later text" : "rejects order-unsafe terminal recovery", () =>
Effect.gen(function* () {
const request = LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("fixture"), prompt: "hello" })
if (completed) {
const response = yield* LLMClient.generate(request)
expect(response.message.content.map((part) => part.type)).toEqual(["compaction", "text"])
return
}
const error = yield* LLMClient.generate(request).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain("Cannot recover a compaction checkpoint")
expect(error.reason.body).toContain("response.completed")
expect(error.reason.http?.status).toBe(200)
}),
)
}
testEffect(
fixedResponse(
sseEvents({
type: "response.completed",
response: { output: [{ type: "compaction", encrypted_content: "opaque" }] },
}),
),
).effect("rejects terminal checkpoints missing an id", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(
LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("fixture"), prompt: "hello" }),
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain("missing its id")
}),
)
@@ -1,30 +0,0 @@
import { expect } from "bun:test"
import { Effect } from "effect"
import { LLM, Message } from "../../src/index.js"
import { OpenAI } from "../../src/providers.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { it } from "../lib/effect.js"
it.effect("conversation lowering excludes generation settings and tool definitions", () =>
Effect.gen(function* () {
const body = yield* OpenResponses.lowerConversation(
LLM.request({
model: OpenAI.configure({ apiKey: "test" }).responses("fixture"),
system: "Keep the context",
messages: [Message.user("hello"), Message.assistant("hi")],
generation: { maxTokens: 100, temperature: 0.5 },
providerOptions: { store: false },
tools: [{ name: "unsupported", description: "Generation only", inputSchema: {}, native: { unsupported: {} } }],
}),
{ id: "open-responses", name: "Open Responses" },
)
expect(body).toEqual({
model: "fixture",
instructions: "Keep the context",
input: [
{ role: "user", content: [{ type: "input_text", text: "hello" }] },
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "hi" }] },
],
})
}),
)
@@ -62,6 +62,62 @@ describe("provider error retention", () => {
)
}
it.effect("classifies a message-less Gemini 429 and retains its event and HTTP context", () =>
Effect.gen(function* () {
const body = JSON.stringify({
error: { code: 429, status: "RESOURCE_EXHAUSTED", details: { opaque: [1, 2] } },
trace: { opaque: "outer" },
})
const error = yield* LLMClient.generate(
LLM.request({ model: Google.configure(options).model("gemini"), prompt: "hello" }),
).pipe(
Effect.provide(
fixedResponse(sseEvents(body), {
headers: { "content-type": "text/event-stream", "x-provider-trace": "trace-1" },
}),
),
Effect.flip,
)
expect(error.message).toBe("RESOURCE_EXHAUSTED")
expect(error.reason._tag).toBe("RateLimit")
expect(error.reason.body).toBe(body)
expect(error.reason.http).toMatchObject({ status: 200, headers: { "x-provider-trace": "trace-1" } })
expect(error.reason.http?.url).toStartWith("https://provider.test/")
}),
)
it.effect("rejects a malformed non-record Gemini error", () =>
Effect.gen(function* () {
const body = JSON.stringify({ error: "RESOURCE_EXHAUSTED", trace: { opaque: "outer" } })
const error = yield* LLMClient.generate(
LLM.request({ model: Google.configure(options).model("gemini"), prompt: "hello" }),
).pipe(Effect.provide(fixedResponse(sseEvents(body))), Effect.flip)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain("Invalid google/gemini stream event")
expect(error.reason.body).toBe(body)
expect(error.reason.http?.status).toBe(200)
}),
)
it.effect("rejects and retains an explicit null Gemini error", () =>
Effect.gen(function* () {
const body = JSON.stringify({ error: null, trace: { opaque: "outer" } })
const error = yield* LLMClient.generate(
LLM.request({ model: Google.configure(options).model("gemini"), prompt: "hello" }),
).pipe(
Effect.provide(fixedResponse(sseEvents(body), { headers: { "x-provider-trace": "trace-null" } })),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.reason.body).toBe(body)
expect(error.reason.http).toMatchObject({ status: 200, headers: { "x-provider-trace": "trace-null" } })
expect(error.reason.http?.url).toStartWith("https://provider.test/")
}),
)
it.effect("retains malformed provider frames and the original decode cause", () =>
Effect.gen(function* () {
const body = '{"type":"error","error":{"message":42,"opaque":{"nested":true}},"trace":"outer"}'
@@ -1,447 +0,0 @@
import { expect } from "bun:test"
import { Effect, Schema } from "effect"
import { LLM, LLMRequest, Message } from "../../src/index.js"
import { LLMClient, Route } from "../../src/route/client.js"
import { Auth } from "../../src/route/auth.js"
import { Endpoint } from "../../src/route/endpoint.js"
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
import { OpenAI, Azure, XAI, Anthropic, AmazonBedrockMantle } from "../../src/providers/index.js"
import { testEffect } from "../lib/effect.js"
import { dynamicResponse, fixedResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
const checkpoint = { type: "compaction", id: "cmp_1", encrypted_content: "opaque" }
const retained = {
type: "message",
role: "user",
id: "msg_1",
status: "completed",
content: [{ type: "input_text", text: "retained" }],
}
const output = [retained, checkpoint]
testEffect(
dynamicResponse(({ request, text, respond }) =>
Effect.sync(() => {
expect(request.headers["x-deployment"]).toBe("fixture")
expect(request.headers["x-override"]).toBe("request")
expect(request.headers["x-default"]).toBe("configured")
expect(request.headers.authorization).toBe("Bearer test")
expect(new URL(request.url).searchParams.get("api-version")).toBe("fixture")
expect(new URL(request.url).searchParams.get("trace")).toBe("request")
if (new URL(request.url).pathname.endsWith("/compact")) {
expect(JSON.parse(text)).toEqual({
model: "overlaid",
input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }],
instructions: "request instructions",
previous_response_id: "resp_previous",
})
return respond(JSON.stringify({ object: "response.compaction", output }))
}
return respond(sseEvents({ type: "response.completed", response: { id: "resp_1" } }))
}),
),
).effect("generation and compaction share deployment headers, defaults, auth, query, and middleware", () =>
Effect.gen(function* () {
const headers: string[] = []
const middleware: string[] = []
const route = Route.make({
id: "compaction-headers",
provider: "openai",
protocol: OpenAIResponses.protocol,
compact: OpenAIResponses.route.compact,
transport: OpenAIResponses.httpTransport,
endpoint: Endpoint.path(({ body }) => `/${body.model}/responses`, {
baseURL: "https://example.com",
query: { "api-version": "fixture" },
}),
auth: Auth.bearer("test"),
headers: ({ request }) => {
expect(request.providerOptions?.store).toBe(false)
headers.push(String(request.model.id))
return { "x-deployment": "fixture", "x-override": "route" }
},
defaults: {
headers: { "x-default": "configured", "x-override": "configured" },
providerOptions: { store: false },
http: { body: { instructions: "default instructions" } },
},
})
const request = LLM.request({
model: route.model({ id: "fixture" }),
prompt: "hello",
system: "system instructions",
http: {
headers: { "x-override": "request" },
query: { trace: "request" },
body: {
model: "overlaid",
instructions: "request instructions",
previous_response_id: "resp_previous",
store: false,
stream: true,
},
},
})
const options: Parameters<typeof LLMClient.compact>[1] = {
http: (request, next) => {
middleware.push(new URL(request.url).pathname)
return next(request)
},
}
yield* LLMClient.generate(request, options)
yield* LLMClient.compact(request, options)
expect(headers).toEqual(["fixture", "fixture"])
expect(middleware).toEqual(["/fixture/responses", "/fixture/responses/compact"])
}),
)
for (const model of [
OpenAI.configure({ apiKey: "test" }).responses("fixture"),
Azure.configure({ apiKey: "test", resourceName: "test" }).responses("fixture"),
XAI.configure({ apiKey: "test" }).responses("fixture"),
]) {
const item = {
type: model.provider === "xai" ? "x_search_call" : "computer_call",
id: "hosted_1",
status: "completed",
}
testEffect(
dynamicResponse(({ request, text, respond }) =>
Effect.sync(() => {
expect(new URL(request.url).pathname).toEndWith("/responses/compact")
expect(JSON.parse(text)).toEqual({ model: "fixture", input: [item], instructions: "Keep the context" })
return respond(JSON.stringify({ object: "response.compaction", output: [checkpoint] }))
}),
),
).effect(`${model.provider} compacts provider-specific history without lowering generation settings`, () =>
Effect.gen(function* () {
const request = LLM.request({
model,
system: "Keep the context",
messages: [
Message.assistant({
type: "tool-result",
id: item.id,
name: item.type,
result: { type: "json", value: item },
providerExecuted: true,
providerMetadata: { [model.route.providerMetadataKey ?? model.provider]: { itemId: item.id } },
}),
],
})
for (const candidate of [
LLMRequest.update(request, {
tools: [
{ name: "unsupported", description: "Generation only", inputSchema: {}, native: { unsupported: {} } },
],
}),
LLMRequest.update(request, { providerOptions: { contextManagement: "invalid-generation-option" } }),
]) {
const error = yield* LLMClient.generate(candidate).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
const response = yield* LLMClient.compact(candidate)
expect(response.messages[0]?.content[0]?.type).toBe("compaction")
}
}),
)
}
const retainedItems = [
retained,
{
type: "message",
id: "msg_assistant",
role: "assistant",
status: "completed",
phase: "commentary",
content: [
{ type: "output_text", text: "First" },
{ type: "output_text", text: "Second" },
],
},
{
type: "reasoning",
id: "rs_1",
summary: [
{ type: "summary_text", text: "Thinking" },
{ type: "summary_text", text: "More thinking" },
],
encrypted_content: "reasoning-state",
},
{ type: "reasoning", id: "rs_2", summary: [], encrypted_content: "hidden-reasoning" },
{
type: "message",
id: "msg_media",
role: "user",
content: [
{ type: "input_image", image_url: "https://example.com/image.png" },
{ type: "input_file", filename: "report.pdf", file_data: "data:application/pdf;base64,cGRm", detail: "high" },
{ type: "input_file", filename: "other.pdf", file_url: "https://example.com/report.pdf", detail: "low" },
],
},
checkpoint,
]
for (const model of [
OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"),
...[undefined, "custom"].map((providerMetadataKey) =>
Route.make({
id: providerMetadataKey ?? "default-metadata",
provider: "openai",
providerMetadataKey,
protocol: OpenAIResponses.protocol,
compact: OpenAIResponses.route.compact,
endpoint: OpenAIResponses.route.endpoint,
transport: OpenAIResponses.httpTransport,
}).model({ id: "fixture" }),
),
]) {
testEffect(
dynamicResponse(({ request, text, respond }) =>
Effect.sync(() => {
if (new URL(request.url).pathname.endsWith("/compact"))
return respond(JSON.stringify({ object: "response.compaction", output: retainedItems }))
expect(JSON.parse(text).input).toEqual(retainedItems)
return respond(sseEvents({ type: "response.completed", response: { id: "resp_1" } }), {
headers: { "content-type": "text/event-stream" },
})
}),
),
).effect(`${model.route.id} retains messages, reasoning, and media through typed conversation parts`, () =>
Effect.gen(function* () {
const request = LLM.request({
model,
prompt: "hello",
})
const compacted = yield* LLMClient.compact(request)
expect(compacted.messages.map((message) => message.role)).toEqual([
"user",
"assistant",
"assistant",
"assistant",
"user",
"assistant",
])
expect(compacted.messages[1]?.content).toEqual([
{ type: "text", text: "First" },
{ type: "text", text: "Second" },
])
expect(compacted.messages[2]?.content.map((part) => part.type)).toEqual(["reasoning", "reasoning"])
expect(compacted.messages[4]?.content.map((part) => part.type)).toEqual(["media", "media", "media"])
const codec = Schema.fromJsonString(Schema.Array(Message))
const messages = Schema.decodeSync(codec)(Schema.encodeSync(codec)(compacted.messages))
yield* LLMClient.generate(LLMRequest.update(request, { messages }))
}),
)
}
for (const overlay of [undefined, { service_tier: "priority", prompt_cache_key: "overridden" }]) {
testEffect(
dynamicResponse(({ text, respond }) =>
Effect.sync(() => {
expect(JSON.parse(text)).toEqual({
model: "fixture",
input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }],
service_tier: overlay?.service_tier ?? "flex",
prompt_cache_key: overlay?.prompt_cache_key ?? "affinity",
prompt_cache_retention: "24h",
prompt_cache_options: { mode: "explicit", ttl: "30m" },
})
return respond(JSON.stringify({ object: "response.compaction", output: [checkpoint] }))
}),
),
).effect(`compact preserves supported request controls${overlay ? " with HTTP overrides" : ""}`, () =>
LLMClient.compact(
LLM.request({
model: OpenAI.configure({ apiKey: "test" }).responses("fixture"),
prompt: "hello",
promptCacheKey: "affinity",
providerOptions: { serviceTier: "flex" },
generation: { maxTokens: 100 },
http: {
body: {
stream: true,
store: false,
prompt_cache_retention: "24h",
prompt_cache_options: { mode: "explicit", ttl: "30m" },
...overlay,
},
},
}),
),
)
}
for (const item of [
{ type: "unknown_provider_item", data: "do not hide in a compaction part" },
{
type: "message",
role: "user",
content: [{ type: "input_image", image_url: "https://example.com/image.png", detail: 42 }],
},
{ type: "message", role: "user", content: [] },
{
type: "message",
role: "assistant",
content: [{ type: "input_image", image_url: "https://example.com/image.png" }],
},
{ type: "message", role: "user", content: [{ type: "input_file", filename: "missing.pdf" }] },
{
type: "message",
role: "user",
content: [{ type: "input_file", filename: "bad.pdf", file_url: "https://example.com/report.pdf", detail: 42 }],
},
{
type: "message",
role: "user",
content: [
{
type: "input_file",
filename: "both.pdf",
file_url: "https://example.com/report.pdf",
file_data: "data:application/pdf;base64,cGRm",
},
],
},
]) {
testEffect(fixedResponse(JSON.stringify({ object: "response.compaction", output: [item, checkpoint] }))).effect(
`rejects unsupported compact output: ${JSON.stringify(item)}`,
() =>
Effect.gen(function* () {
const error = yield* LLMClient.compact(
LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"), prompt: "hello" }),
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.reason.body).toContain(JSON.stringify(item))
expect(error.reason.http?.status).toBe(200)
}),
)
}
for (const model of [
OpenAI.configure({ apiKey: "test" }).responses("fixture"),
Azure.configure({ apiKey: "test", resourceName: "test" }).responses("fixture"),
XAI.configure({ apiKey: "test" }).responses("fixture"),
]) {
const images = [undefined, "low", "high", "auto"].map((detail) => ({
type: "input_image",
image_url: "https://example.com/image.png",
...(detail === undefined ? {} : { detail }),
}))
testEffect(
dynamicResponse(({ request, text, respond }) =>
Effect.sync(() => {
if (new URL(request.url).pathname.endsWith("/compact"))
return respond(
JSON.stringify({
object: "response.compaction",
output: [{ type: "message", role: "user", content: images }, checkpoint],
}),
)
expect(JSON.parse(text).input[0].content).toEqual(images)
return respond(sseEvents({ type: "response.completed", response: { id: "resp_1" } }))
}),
),
).effect(`${model.provider} preserves retained image detail through serialization and replay`, () =>
Effect.gen(function* () {
const request = LLM.request({ model, prompt: "hello" })
const compacted = yield* LLMClient.compact(request)
const codec = Schema.fromJsonString(Schema.Array(Message))
const messages = Schema.decodeSync(codec)(Schema.encodeSync(codec)(compacted.messages))
yield* LLMClient.generate(LLMRequest.update(request, { messages }))
}),
)
}
testEffect(fixedResponse("must not execute")).effect("xAI rejects automatic compaction options", () =>
Effect.gen(function* () {
const request = LLMRequest.update(
LLM.request({ model: XAI.configure({ apiKey: "test" }).responses("grok-4.6"), prompt: "hello" }),
{ providerOptions: { contextManagement: [{ type: "compaction" }] } },
)
const error = yield* LLMClient.generate(request).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
expect(error.message).toContain("LLMClient.compact")
}),
)
for (const model of [
OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"),
Azure.configure({ apiKey: "test", resourceName: "test" }).responses("deployment"),
XAI.configure({ apiKey: "test" }).responses("grok-4.6"),
]) {
testEffect(
dynamicResponse(({ request, text, respond }) =>
Effect.sync(() => {
const body = JSON.parse(text)
expect(request.method).toBe("POST")
expect(request.headers[model.provider === "azure" ? "api-key" : "authorization"]).toBe(
model.provider === "azure" ? "test" : "Bearer test",
)
if (new URL(request.url).pathname.endsWith("/responses/compact")) {
expect(body).toEqual({
model: model.id,
input: [{ role: "user", content: [{ type: "input_text", text: "original" }] }],
instructions: "system",
})
return respond(
JSON.stringify({
object: "response.compaction",
output,
usage: { input_tokens: 1000, output_tokens: 10, total_tokens: 1010 },
}),
{ headers: { "content-type": "application/json" } },
)
}
expect(new URL(request.url).pathname.endsWith("/responses")).toBe(true)
expect(body.input).toEqual([...output, { role: "user", content: [{ type: "input_text", text: "continue" }] }])
return respond(sseEvents({ type: "response.completed", response: { id: "resp_1", output: [] } }), {
headers: { "content-type": "text/event-stream" },
})
}),
),
).effect(`${model.provider} explicitly compacts and replays the entire canonical window`, () =>
Effect.gen(function* () {
const request = LLM.request({ model, prompt: "original", system: "system", http: { body: { store: false } } })
const compacted = yield* LLMClient.compact(request)
expect(compacted.usage?.totalTokens).toBe(1010)
expect(compacted.messages.map((message) => message.role)).toEqual(["user", "assistant"])
expect(compacted.messages[0]?.content).toEqual([{ type: "text", text: "retained" }])
expect(compacted.messages[1]?.content).toEqual([
{ type: "compaction", provider: model.provider, id: "cmp_1", encrypted: "opaque" },
])
const codec = Schema.fromJsonString(Schema.Array(Message))
const messages = Schema.decodeSync(codec)(Schema.encodeSync(codec)(compacted.messages))
yield* LLMClient.generate(LLMRequest.update(request, { messages: [...messages, Message.user("continue")] }))
}),
)
}
for (const model of [
Anthropic.configure({ apiKey: "test" }).model("claude-opus-4-6"),
AmazonBedrockMantle.configure({ apiKey: "test" }).responses("model"),
]) {
testEffect(fixedResponse("must not execute")).effect(
`${model.route.id} does not inherit an unsupported compact endpoint`,
() =>
Effect.gen(function* () {
// @ts-expect-error Untyped callers must still receive the runtime capability error.
const error = yield* LLMClient.compact(LLM.request({ model, prompt: "hello" })).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
}),
)
}
testEffect(
fixedResponse(JSON.stringify({ object: "response.compaction", output: [retained], debug: "original payload" })),
).effect("invalid explicit compaction preserves the original response and HTTP context", () =>
Effect.gen(function* () {
const error = yield* LLMClient.compact(
LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"), prompt: "hello" }),
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.reason.body).toContain("original payload")
expect(error.reason.http?.status).toBe(200)
}),
)
@@ -1,60 +0,0 @@
import { expect } from "bun:test"
import { Effect, Schema } from "effect"
import { LLM, Message } from "../../src/index.js"
import { OpenAI, Azure, XAI } from "../../src/providers.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
for (const model of [
OpenAI.configure({ apiKey: "test" }).responses("fixture"),
Azure.configure({ apiKey: "test", resourceName: "test" }).responses("fixture"),
XAI.configure({ apiKey: "test" }).responses("fixture"),
]) {
it.effect(`${model.provider} preserves image detail through message serialization and lowering`, () =>
Effect.gen(function* () {
const details = [undefined, "low", "high", "auto"]
const message = Message.user(
details.map((detail) => ({
type: "media",
mediaType: "image/png",
data: "https://example.com/image.png",
providerMetadata:
detail === undefined ? undefined : { [model.route.providerMetadataKey ?? model.provider]: { detail } },
})),
)
const codec = Schema.fromJsonString(Message)
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [Schema.decodeSync(codec)(Schema.encodeSync(codec)(message))],
}),
)
expect(prepared.body.input[0].content).toEqual(
details.map((detail) => ({
type: "input_image",
image_url: "https://example.com/image.png",
detail,
})),
)
}),
)
}
it.effect("rejects malformed image detail instead of silently discarding it", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
LLM.request({
model: OpenAI.configure({ apiKey: "test" }).responses("fixture"),
messages: [
Message.user({
type: "media",
mediaType: "image/png",
data: "https://example.com/image.png",
providerMetadata: { openai: { detail: 42 } },
}),
],
}),
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
}),
)
@@ -82,6 +82,32 @@ describe("Open Responses completed item text", () => {
expect(response.events.filter(LLMEvent.is.textStart)).toEqual([])
}),
)
it.effect("assembles a done-only message once across replayed item events", () =>
Effect.gen(function* () {
const item = {
type: "message",
id: "msg_1",
content: [{ type: "output_text", text: "Recovered" }],
}
const response = yield* generate(
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Ignored after resume" },
{ type: "response.output_item.done", item },
{ type: "response.output_item.added", item },
{ type: "response.output_item.done", item },
completed,
)
expect(response.text).toBe("Recovered")
expect(response.message.content).toEqual([
{
type: "text",
text: "Recovered",
providerMetadata: { "openai-compatible": { itemId: "msg_1" } },
},
])
expect(response.events.filter(LLMEvent.is.textEnd)).toHaveLength(1)
}),
)
})
describe("Open Responses completed item reasoning", () => {
@@ -216,7 +216,63 @@ describe("Open Responses basic-item lifecycles", () => {
])
}),
)
it.effect("allows a message to be registered again without inheriting its previous phase", () =>
it.effect("preserves non-empty done-only message content without replaying duplicates", () =>
Effect.gen(function* () {
const text = {
type: "message",
id: "msg_text",
content: [{ type: "output_text", text: "Done-only text." }],
}
const refusal = {
type: "message",
id: "msg_refusal",
content: [{ type: "refusal", refusal: "Done-only refusal." }],
}
const events = yield* collect(
{ type: "response.output_item.done", item: text },
{ type: "response.output_item.done", item: text },
{
type: "response.output_item.done",
item: { type: "message", id: "msg_empty", content: [{ type: "output_text", text: "" }] },
},
{
type: "response.output_item.done",
item: { type: "message", id: "msg_empty", content: [{ type: "output_text", text: "Late" }] },
},
{ type: "response.output_item.done", item: refusal },
{ type: "response.output_item.done", item: refusal },
completed,
)
expect(events.filter((event) => event.type.startsWith("text-"))).toEqual([
{
type: "text-start",
id: "msg_text",
providerMetadata: { "openai-compatible": { itemId: "msg_text" } },
},
{
type: "text-end",
id: "msg_text",
text: "Done-only text.",
providerMetadata: { "openai-compatible": { itemId: "msg_text" } },
},
{
type: "text-start",
id: "msg_refusal",
providerMetadata: { "openai-compatible": { itemId: "msg_refusal" } },
},
{
type: "text-end",
id: "msg_refusal",
text: "Done-only refusal.",
providerMetadata: { "openai-compatible": { itemId: "msg_refusal" } },
},
])
}),
)
it.effect("treats a repeated message lifecycle as replay", () =>
Effect.gen(function* () {
const events = yield* collect(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
@@ -233,9 +289,44 @@ describe("Open Responses basic-item lifecycles", () => {
id: "msg_1",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
{ type: "text-end", id: "msg_1", providerMetadata: { "openai-compatible": { itemId: "msg_1" } } },
])
expect(events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["First", "Second"])
expect(events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["First"])
}),
)
it.effect("ignores a stale done-only message while another message is active", () =>
Effect.gen(function* () {
const events = yield* collect(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Draft" },
{
type: "response.output_item.done",
item: { type: "message", id: "msg_2", content: [{ type: "output_text", text: "Recovered" }] },
},
{
type: "response.output_item.done",
item: { type: "message", id: "msg_1", content: [{ type: "output_text", text: "Final" }] },
},
{
type: "response.output_item.done",
item: { type: "message", id: "msg_2", content: [{ type: "output_text", text: "Late" }] },
},
completed,
)
expect(events.filter((event) => event.type.startsWith("text-"))).toEqual([
{
type: "text-start",
id: "msg_1",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
{ type: "text-delta", id: "msg_1", text: "Draft" },
{
type: "text-end",
id: "msg_1",
text: "Final",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
])
}),
)
;[undefined, "fc_1"].forEach((id) => {
+1 -62
View File
@@ -1,18 +1,5 @@
import { describe, expect } from "bun:test"
import {
AIError,
CompactionPart,
CompactionResponse,
LanguageModel,
LLM,
LLMClient,
LLMEvent,
LLMRequest,
Message,
ProviderID,
RateLimitError,
} from "../src/index.js"
import { OpenAI } from "../src/providers.js"
import { AIError, LanguageModel, LLM, LLMClient, LLMEvent, LLMRequest, RateLimitError } from "../src/index.js"
import { OpenAIChat } from "../src/protocols/openai-chat.js"
import { TestLLM } from "../src/testing.js"
import { Effect, Fiber, Latch, Stream } from "effect"
@@ -79,54 +66,6 @@ describe("TestLLM legacy client", () => {
})
describe("TestLLM first-class client", () => {
it.effect("rejects response fixtures for the wrong operation", () =>
Effect.gen(function* () {
const client = yield* TestLLM.Test
const request = LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("fixture"), prompt: "hello" })
yield* client.push(TestLLM.stop(), new CompactionResponse({ messages: [] }))
expect(yield* client.compact(request).pipe(Effect.catchDefect(Effect.succeed))).toBe(
"TestLLM compaction requires a CompactionResponse",
)
expect(yield* client.generate(request).pipe(Effect.catchDefect(Effect.succeed))).toBe(
"TestLLM generation requires an event response",
)
}),
)
it.effect("scripts replacement windows with the same lazy recording, gates, and fallback controls", () =>
Effect.gen(function* () {
const client = yield* TestLLM.Test
const request = LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("fixture"), prompt: "hello" })
const compacted = new CompactionResponse({
messages: [
Message.user("retained input"),
Message.assistant(CompactionPart.make({ provider: ProviderID.make("openai"), encrypted: "checkpoint" })),
Message.user("retained tail"),
],
})
yield* client.push(compacted, TestLLM.text("continued", "answer"))
const operation = LLMClient.compact(request)
expect(yield* client.requests()).toEqual([])
const gate = yield* client.gate()
const fiber = yield* operation.pipe(Effect.forkChild({ startImmediately: true }))
yield* gate.started
yield* client.wait(1)
expect(fiber.pollUnsafe()).toBeUndefined()
yield* gate.release
expect(yield* Fiber.join(fiber)).toBe(compacted)
const next = LLMRequest.update(request, { messages: compacted.messages })
expect((yield* LLMClient.generate(next)).text).toBe("continued")
yield* client.serve((observed) => {
expect(observed).toBe(next)
return compacted
})
expect(yield* LLMClient.compact(next)).toBe(compacted)
yield* client.always(compacted)
expect(yield* LLMClient.compact(next)).toBe(compacted)
expect(yield* client.requests()).toEqual([request, next, next, next])
}),
)
it.effect("provides the same object under normal and test tags with snapshot observations", () =>
Effect.gen(function* () {
const llm = yield* TestLLM.Test
@@ -1,5 +1,16 @@
import { expect, story } from "../../storybook/playwright/story"
story("raises the docked composer only in dark mode", async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--empty-draft")
const composer = component.locator('[data-component="composer"]')
await page.locator("html").evaluate((root) => root.setAttribute("data-color-scheme", "light"))
await expect(composer).toHaveCSS("background-color", "rgb(255, 255, 255)")
await page.locator("html").evaluate((root) => root.setAttribute("data-color-scheme", "dark"))
await expect(composer).toHaveCSS("background-color", "rgb(36, 36, 36)")
})
for (const draft of ["empty-draft", "multiline-draft", "mixed-attachments"]) {
story(`select all stays inside the composer with ${draft}`, async ({ mount, page }) => {
const component = await mount(`opencode-composer-flow--${draft}`)
@@ -123,7 +123,7 @@ async function setup(page: Page) {
})
await page.addInitScript(
({ directory, server, sessionID }) => {
({ directory, server, sessionID, tabKey }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
@@ -131,10 +131,8 @@ async function setup(page: Page) {
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } }))
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } }))
localStorage.setItem(
"opencode.global.dat:review-panel-v2",
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
@@ -144,6 +142,6 @@ async function setup(page: Page) {
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ directory, server, sessionID },
{ directory, server, sessionID, tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}` },
)
}
@@ -10,7 +10,7 @@ test("status drawer dismisses and reopens after button, backdrop, Escape, and dr
.locator('[data-slot="session-mobile-view-navigation"]')
.getByRole("button", { name: "More options", exact: true })
const drawer = page.getByRole("dialog", { name: "Status", exact: true })
const overlay = page.locator('[data-slot="mobile-status-overlay"]')
const overlay = page.locator('[data-slot="mobile-drawer-overlay"]')
for (const dismissal of ["button", "backdrop", "escape", "drag", "button"] as const) {
await more.click()
@@ -21,7 +21,7 @@ test("status drawer dismisses and reopens after button, backdrop, Escape, and dr
if (dismissal === "backdrop") await overlay.click({ position: { x: 10, y: 10 } })
if (dismissal === "escape") await page.keyboard.press("Escape")
if (dismissal === "drag") {
const handle = drawer.locator('[data-slot="mobile-status-drag-handle"]')
const handle = drawer.locator('[data-slot="mobile-drawer-handle"]')
const bounds = await handle.boundingBox()
expect(bounds).not.toBeNull()
await page.mouse.move(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2)
@@ -80,7 +80,7 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
})
await page.addInitScript(
({ directory, server, sessionID }) => {
({ directory, server, sessionID, tabKey }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
@@ -88,10 +88,8 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } }))
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } }))
localStorage.setItem(
"opencode.global.dat:review-panel-v2",
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
@@ -101,7 +99,7 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ directory, server, sessionID },
{ directory, server, sessionID, tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}` },
)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
@@ -64,7 +64,7 @@ test("project Extensions stays inside settings while plugins load", async ({ pag
location: project ? { directory: project } : {},
data: (project ? ["shared-plugin", "project-plugin"] : ["shared-plugin"]).map((id) => ({
id,
source: { type: "package", package: id },
source: { type: "package", target: id },
state: { status: "active" },
features: { server: true },
})),
@@ -61,7 +61,7 @@ test("opens and searches project files inline", async ({ page }) => {
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ directory, server, sessionID }) => {
({ directory, server, sessionID, tabKey }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
@@ -69,10 +69,8 @@ test("opens and searches project files inline", async ({ page }) => {
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } }))
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } }))
localStorage.setItem(
"opencode.global.dat:review-panel-v2",
JSON.stringify({ sidebarOpened: false, sidebarWidth: 240, expandMode: "collapse" }),
@@ -82,7 +80,7 @@ test("opens and searches project files inline", async ({ page }) => {
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ directory, server, sessionID },
{ directory, server, sessionID, tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}` },
)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
@@ -22,6 +22,7 @@ test("restores review mode and selected file per session", async ({ page }) => {
await selectFile(page, "alpha.ts")
await switchSession(page, titleB)
await page.getByRole("button", { name: "Toggle review" }).click()
await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
await selectFile(page, "gamma.ts")
@@ -18,37 +18,72 @@ const PROBE = "original"
test.use({ viewport: { width: 1440, height: 900 } })
// The v2 review pane's diff data is workspace-scoped: switching between session
// tabs in the same workspace must update its parameters reactively instead of
// tearing the pane down and remounting it (which flickers).
test("keeps the v2 review pane mounted when switching session tabs in a workspace", async ({ page }) => {
// The review pane's data is workspace-scoped, but visibility belongs to each
// session tab. Switching tabs must keep the pane mounted without opening it.
test("keeps review visibility per tab and the pane mounted across tab switches", async ({ page }) => {
await setup(page)
await page.goto(sessionHref(sessionA))
await expectSessionTitle(page, titleA)
await page.getByRole("button", { name: "Toggle review" }).click()
await expect
.poll(() =>
page
.locator('[data-slot="session-chat-panel"]')
.evaluate((element) => getComputedStyle(element).transitionDuration),
)
.toContain("0.24s")
const reviewTab = page.locator("#session-side-panel-review-tab")
const reviewTabPanel = page.locator("#session-side-panel-review-tabpanel")
const chatPanel = page.locator('[data-slot="session-chat-panel"]')
await expect(reviewTab).toHaveAttribute("aria-controls", "session-side-panel-review-tabpanel")
await expect(reviewTabPanel).toHaveAttribute("id", "session-side-panel-review-tabpanel")
const review = page.locator('#review-panel [data-component="session-review-v2"]')
await expectAppVisible(review)
await expect(chatPanel).toHaveCSS("width", "580px")
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
await writeProbe(page)
await switchTab(page, titleB)
await expectSessionTitle(page, titleB)
await expectAppVisible(review)
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
await expect(review).toBeHidden()
await expect
.poll(() =>
page
.locator('[data-slot="session-chat-panel"]')
.evaluate((element) => getComputedStyle(element).transitionDuration),
)
.toBe("0s")
expect(await readProbe(page)).toBe(PROBE)
await page.getByRole("button", { name: "Toggle review" }).click()
await expectAppVisible(review)
await expect(chatPanel).toHaveCSS("width", "520px")
await switchTab(page, titleA)
await expectSessionTitle(page, titleA)
await expectAppVisible(review)
await expect(chatPanel).toHaveCSS("width", "580px")
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
expect(await readProbe(page)).toBe(PROBE)
await expect
.poll(() =>
page.evaluate(
({ key }) => {
const panes = JSON.parse(localStorage.getItem("opencode.window.browser.dat:tabs.panes") ?? "{}")
return panes[key]?.review
},
{ key: `${server}\n${sessionHref(sessionA)}` },
),
)
.toBe(true)
await page.reload()
await expectSessionTitle(page, titleA)
await expectAppVisible(review)
await expect(chatPanel).toHaveCSS("width", "580px")
const viewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
await viewport.hover()
await page.mouse.wheel(0, 100_000)
@@ -102,7 +137,7 @@ async function setup(page: Page) {
})
await page.addInitScript(
({ directory, server, sessions }) => {
({ directory, server, sessions, panes }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
@@ -114,8 +149,19 @@ async function setup(page: Page) {
"opencode.window.browser.dat:tabs",
JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
)
if (!localStorage.getItem("opencode.window.browser.dat:tabs.panes")) {
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify(panes))
}
},
{
directory,
server,
sessions: [sessionA, sessionB],
panes: {
[`${server}\n${sessionHref(sessionA)}`]: { sessionWidth: 580 },
[`${server}\n${sessionHref(sessionB)}`]: { sessionWidth: 520 },
},
},
{ directory, server, sessions: [sessionA, sessionB] },
)
}
@@ -123,12 +123,17 @@ test("uses side placement by default and supports the terminal across the bottom
}),
)
await page.routeWebSocket("**/api/pty/pty_review_terminal/connect", () => undefined)
await page.addInitScript(() => {
localStorage.setItem(
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
})
await page.addInitScript(
({ tabKey, server, sessionID }) => {
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } }))
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } }))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}`, server, sessionID },
)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionReady(page, { server, sessionID, title })
@@ -85,7 +85,7 @@ test("extensions opens without waiting for MCPs or plugins", async ({ page }) =>
data: [
{
id: "demo-plugin",
source: { type: "package", package: "demo-plugin" },
source: { type: "package", target: "demo-plugin" },
state: { status: "active" },
features: { server: true },
},
@@ -252,8 +252,15 @@ test("focuses a terminal created from the new-terminal button", async ({ page })
function seedCachedTerminal(page: Page) {
return page.addInitScript(
({ terminalKey, ptyID }) => {
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ terminal: { height: 320, opened: true } }))
({ terminalKey, ptyID, tabKey, server, sessionID }) => {
localStorage.setItem(
"opencode.window.browser.dat:tabs.panes",
JSON.stringify({ [tabKey]: { terminal: true, terminalHeight: 320 } }),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
localStorage.setItem(
terminalKey,
JSON.stringify({
@@ -262,7 +269,13 @@ function seedCachedTerminal(page: Page) {
}),
)
},
{ terminalKey: terminalStorageKey(), ptyID },
{
terminalKey: terminalStorageKey(),
ptyID,
tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}`,
server,
sessionID,
},
)
}
@@ -16,10 +16,9 @@ const PROBE = "original"
test.use({ viewport: { width: 1440, height: 900 } })
// Terminals are workspace-scoped: switching between session tabs in the same
// workspace must keep the terminal mounted and its PTY connection open instead
// of tearing it down and reconnecting.
test("keeps the terminal session alive when switching session tabs in a workspace", async ({ page }) => {
// Terminal processes are workspace-scoped, but panel visibility belongs to each
// session tab. Switching tabs must keep the PTY alive without opening its panel.
test("keeps terminal visibility per tab and the PTY alive across tab switches", async ({ page }) => {
const connections = await setup(page)
await page.goto(sessionHref(sessionA))
@@ -27,7 +26,10 @@ test("keeps the terminal session alive when switching session tabs in a workspac
await page.keyboard.press("Control+Backquote")
const terminal = page.locator('[data-component="terminal"]')
const terminalPanel = page.locator('[data-component="terminal-panel"]')
await expect(terminal).toBeVisible()
await expect(terminalPanel).toHaveAttribute("data-size-animated", "true")
await expect(terminalPanel).toHaveCSS("height", "300px")
await expect.poll(() => connections.length).toBe(1)
const connection = new URL(connections[0]!)
expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`)
@@ -37,15 +39,26 @@ test("keeps the terminal session alive when switching session tabs in a workspac
await switchTab(page, titleB)
await expectSessionTitle(page, titleB)
await expect(terminal).toBeVisible()
await expect(terminal).toBeHidden()
await expect(terminalPanel).toHaveAttribute("data-size-animated", "false")
expect(await readProbe(page)).toBe(PROBE)
expect(connections.length).toBe(1)
await page.keyboard.press("Control+Backquote")
await expect(terminal).toBeVisible()
await expect(terminalPanel).toHaveCSS("height", "180px")
await switchTab(page, titleA)
await expectSessionTitle(page, titleA)
await expect(terminal).toBeVisible()
await expect(terminalPanel).toHaveCSS("height", "300px")
expect(await readProbe(page)).toBe(PROBE)
expect(connections.length).toBe(1)
await page.reload()
await expectSessionTitle(page, titleA)
await expect(terminal).toBeVisible()
await expect(terminalPanel).toHaveCSS("height", "300px")
})
type Probed = HTMLElement & { __e2eProbe?: string }
@@ -120,7 +133,7 @@ async function setup(page: Page) {
})
await page.addInitScript(
({ directory, server, sessions }) => {
({ directory, server, sessions, panes }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
@@ -132,8 +145,20 @@ async function setup(page: Page) {
"opencode.window.browser.dat:tabs",
JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
)
if (!localStorage.getItem("opencode.window.browser.dat:tabs.panes")) {
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify(panes))
}
localStorage.setItem("settings.v3", JSON.stringify({ general: { terminalPlacement: "bottom" } }))
},
{
directory,
server,
sessions: [sessionA, sessionB],
panes: {
[`${server}\n${sessionHref(sessionA)}`]: { terminalHeight: 300 },
[`${server}\n${sessionHref(sessionB)}`]: { terminalHeight: 180 },
},
},
{ directory, server, sessions: [sessionA, sessionB] },
)
return connections
}
@@ -75,11 +75,12 @@ for (const theme of ["light", "dark"] as const) {
await expectBackground(view.send, "contrast")
const message = page.locator('[data-slot="user-message-text"]')
await expect(message).toHaveText("Check this fixture workspace.")
await expectBackground(
await expectToken(
message,
scenario.accent ? "accent" : theme === "light" ? "layer-02" : "layer-01",
"background-color",
scenario.accent ? "--v2-background-bg-accent" : "--v2-state-bg-info",
)
await expectToken(message, "color", scenario.accent ? "--v2-text-text-contrast" : "--v2-text-text-accent")
})
}
@@ -245,3 +246,16 @@ async function expectBackground(element: Locator, token: string, property = "bac
}, token)
await expect(element).toHaveCSS(property, new RegExp(color.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")))
}
async function expectToken(element: Locator, property: string, token: string) {
const color = await element.evaluate((element, token) => {
const probe = document.createElement("span")
probe.hidden = true
probe.style.color = `var(${token})`
element.append(probe)
const color = getComputedStyle(probe).color
probe.remove()
return color
}, token)
await expect(element).toHaveCSS(property, color)
}
+2 -2
View File
@@ -9,13 +9,13 @@
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
"purpose": "maskable"
}
],
"theme_color": "#080808",
@@ -1,3 +1,7 @@
[data-component="composer-editor"]:empty::before {
content: "\200B";
}
[data-color-scheme="dark"] [data-component="composer"][data-dock-border-underlay="true"] {
background: var(--v2-background-bg-layer-01);
}
+1 -3
View File
@@ -114,10 +114,8 @@ export function ComposerEditor(props: ComposerEditorProps) {
<form
data-component="composer"
data-dock-border-underlay={props.borderUnderlay ? "true" : undefined}
class="group/composer relative min-h-[96px] w-full overflow-clip rounded-xl"
class="group/composer relative min-h-[96px] w-full overflow-clip rounded-xl bg-v2-background-bg-base"
classList={{
"bg-v2-background-bg-layer-01": props.borderUnderlay,
"bg-v2-background-bg-base": !props.borderUnderlay,
"shadow-[var(--v2-elevation-raised)]": !props.borderUnderlay,
"border border-v2-icon-icon-info border-dashed": state.drag === "active",
}}
+6 -1
View File
@@ -6,6 +6,7 @@ import { AppBaseProviders, AppInterface } from "@/app"
import { loadInitialLocale } from "@/runtime/i18n/language"
import { PlatformProvider } from "@/runtime/platform/platform"
import { createWebPlatform } from "@/runtime/platform/web"
import { isStandalone, PwaRoutePersistence, restorePwaRoute } from "@/runtime/platform/pwa"
import en from "@/runtime/i18n/en"
import zh from "@/runtime/i18n/zh"
import { authFromToken } from "@/runtime/server/api"
@@ -71,6 +72,8 @@ if (root instanceof HTMLElement && root.dataset.opencodeMounted === undefined) {
void loadInitialLocale().then((locale) => {
const auth = authFromToken(new URLSearchParams(location.search).get("auth_token"))
clearAuthToken()
const standalone = isStandalone()
if (standalone) restorePwaRoute()
const server: ServerConnection.Http = {
type: "http",
authToken: !!auth,
@@ -87,7 +90,9 @@ if (root instanceof HTMLElement && root.dataset.opencodeMounted === undefined) {
defaultServer={ServerConnection.Key.make(web.defaultServerUrl)}
canonicalLocalServer={ServerConnection.key(server)}
servers={[server]}
/>
>
{standalone && <PwaRoutePersistence />}
</AppInterface>
</AppBaseProviders>
</PlatformProvider>
),
@@ -8,7 +8,7 @@ describe("pluginLabels", () => {
{ id: "opencode.internal", source: { type: "builtin" }, state: { status: "active" }, features: { server: true } },
{
id: "package-plugin",
source: { type: "package", package: "example" },
source: { type: "package", target: "example" },
state: { status: "active" },
features: { server: true },
},
+1 -1
View File
@@ -2,7 +2,7 @@ import type { PluginInfo } from "@opencode-ai/client"
export function pluginLabel(plugin: PluginInfo) {
if (plugin.id) return plugin.id
if (plugin.source.type === "package") return plugin.source.package
if (plugin.source.type === "package") return plugin.source.target
if (plugin.source.type === "local") return plugin.source.path
return plugin.source.type
}
+19 -10
View File
@@ -1,16 +1,25 @@
import { createMemo, type Accessor } from "solid-js"
import createPresence from "solid-presence"
export function createAnimatedPresence<T>(value: Accessor<T | undefined>, element: Accessor<HTMLElement | null>) {
const animation = createMemo<{ show: boolean; animate: boolean; value: T | undefined }>((previous) => {
const current = value()
const show = current !== undefined
return {
show,
animate: previous !== undefined && (previous.animate || previous.show !== show),
value: current ?? previous?.value,
}
})
export function createAnimatedPresence<T>(
value: Accessor<T | undefined>,
element: Accessor<HTMLElement | null>,
identity?: Accessor<unknown>,
) {
const animation = createMemo<{ identity?: unknown; show: boolean; animate: boolean; value: T | undefined }>(
(previous) => {
const currentIdentity = identity?.()
const current = value()
const show = current !== undefined
const same = !identity || previous?.identity === currentIdentity
return {
identity: currentIdentity,
show,
animate: previous !== undefined && same && (previous.animate || previous.show !== show),
value: current ?? (same ? previous?.value : undefined),
}
},
)
const presence = createPresence({ show: () => animation().show, element })
return {
...presence,
+43
View File
@@ -0,0 +1,43 @@
import { useLocation } from "@solidjs/router"
import { createEffect } from "solid-js"
const LAST_ROUTE_KEY = "opencode.pwa.last-route"
export function isStandalone() {
return (
window.matchMedia("(display-mode: standalone)").matches ||
("standalone" in navigator && navigator.standalone === true)
)
}
export function restorePwaRoute() {
if (location.pathname !== "/" || location.search || location.hash) return
try {
const value = localStorage.getItem(LAST_ROUTE_KEY)
if (!value) return
const url = new URL(value, location.origin)
if (url.origin !== location.origin || url.searchParams.has("auth_token")) return
if (
url.pathname !== "/" &&
url.pathname !== "/new-session" &&
!/^\/server\/[^/]+\/session\/[^/]+$/.test(url.pathname)
)
return
history.replaceState(history.state, "", url.pathname + url.search + url.hash)
} catch {
// Storage may be unavailable; keep the launch URL in that case.
}
}
export function PwaRoutePersistence() {
const location = useLocation()
createEffect(() => {
const value = location.pathname + location.search + location.hash
try {
localStorage.setItem(LAST_ROUTE_KEY, value)
} catch {
// Navigation must still work when storage is unavailable or full.
}
})
return null
}
@@ -155,6 +155,16 @@ describe("isSessionNotFoundError", () => {
expect(isSessionNotFoundError(new Error(body.message, { cause: { body, status: 404 } }), body.sessionID)).toBe(true)
})
test("matches a structured error stored directly as the cause", () => {
const body = {
_tag: "SessionNotFoundError",
sessionID: "ses_missing",
message: "Session not found",
} satisfies SessionNotFoundError
expect(isSessionNotFoundError(new Error("Unknown error", { cause: body }), body.sessionID)).toBe(true)
})
test("rejects errors for other sessions and other 404 responses", () => {
const body = {
_tag: "SessionNotFoundError",
+3 -4
View File
@@ -36,10 +36,9 @@ export function formatServerError(error: unknown, translate?: Translator, fallba
}
function unwrapNamedError(error: unknown): unknown {
if (error instanceof Error && error.cause && typeof error.cause === "object" && "body" in error.cause) {
return (error.cause as Record<string, unknown>).body
}
return error
if (!(error instanceof Error) || !error.cause || typeof error.cause !== "object") return error
if ("body" in error.cause) return (error.cause as Record<string, unknown>).body
return error.cause
}
// Client-synthesized session not-found errors share one constructor and
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { buildFileTreeV2Model, flattenFileTreeV2, flattenLiveFileTreeV2 } from "./file-tree-v2-model"
import { buildFileTreeV2Model, flattenFileTreeV2, flattenLiveFileTreeV2, sortFileTreeV2Paths } from "./file-tree-v2-model"
import type { FileNode } from "@/runtime/server/types"
describe("buildFileTreeV2Model", () => {
@@ -45,6 +45,33 @@ describe("buildFileTreeV2Model", () => {
})
})
describe("sortFileTreeV2Paths", () => {
test("orders navigation depth-first with directories before sibling files", () => {
const paths = ["README.md", "src/a.ts", "src/lib/z.ts", "docs/guide.md", "src/lib/b.ts"]
expect(sortFileTreeV2Paths(paths)).toEqual([
"docs/guide.md",
"src/lib/b.ts",
"src/lib/z.ts",
"src/a.ts",
"README.md",
])
expect(paths[0]).toBe("README.md")
})
test("preserves original paths for selection", () => {
expect(sortFileTreeV2Paths(["README.md", "src\\lib\\a.ts", "/docs//guide.md/"])).toEqual([
"/docs//guide.md/",
"src\\lib\\a.ts",
"README.md",
])
})
test("handles an empty file list", () => {
expect(sortFileTreeV2Paths([])).toEqual([])
})
})
describe("flattenLiveFileTreeV2", () => {
test("flattens live children using original paths for nested lookups", () => {
const nodes: Record<string, FileNode[]> = {
@@ -76,6 +76,12 @@ export function flattenFileTreeV2(model: FileTreeV2Model, expanded: (path: strin
return rows
}
export function sortFileTreeV2Paths(paths: readonly string[]) {
return flattenFileTreeV2(buildFileTreeV2Model(paths), () => true)
.filter((row) => row.node.type === "file")
.map((row) => row.node.originalPath)
}
export function flattenLiveFileTreeV2(
children: (path: string) => readonly FileNode[],
expanded: (path: string) => boolean,
+1
View File
@@ -129,6 +129,7 @@ export function useSessionModel() {
layout: {
tabs: layout.tabs,
view: layout.view,
tabKey: layout.tabKey,
},
ownership: createSessionOwnership(layout.sessionKey),
tabs: {
+6 -2
View File
@@ -18,6 +18,7 @@ import type {
SessionReviewLineComment,
} from "@opencode-ai/session-ui/session-review"
import FileTreeV2 from "@/session/files/file-tree-v2"
import { sortFileTreeV2Paths } from "@/session/files/file-tree-v2-model"
import { useLanguage } from "@/runtime/i18n/language"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServerSDK } from "@/runtime/server/client"
@@ -83,6 +84,9 @@ export function ReviewPanelView(
),
)
const searching = createMemo(() => props.state.filter().trim().length > 0)
const navigationFiles = createMemo(() =>
searching() || props.fileList === "flat" ? filteredFiles() : sortFileTreeV2Paths(filteredFiles()),
)
const kinds = createMemo(() => reviewDiffKinds(diffs()))
// Changes-only trees omit "M" — every row is already a change; A/D stay visible.
const treeKinds = createMemo(() => new Map([...kinds()].filter(([, kind]) => kind !== "mix")))
@@ -93,7 +97,7 @@ export function ReviewPanelView(
if (focus && diffs().some((diff) => diff.file === focus.file)) return focus.file
const active = props.activeFile
if (searching()) return active
const files = filteredFiles()
const files = navigationFiles()
if (active && files.includes(active)) return active
return files[0]
})
@@ -141,7 +145,7 @@ export function ReviewPanelView(
/>
}
activeFile={activeDiff()}
files={filteredFiles()}
files={navigationFiles()}
onSelectFile={props.onSelectFile}
diffStyle={props.diffStyle}
onDiffStyleChange={props.onDiffStyleChange}
+3 -2
View File
@@ -12,6 +12,7 @@ export function createSessionScreenLayout(session: SessionModel) {
const layout = useLayout()
const settings = useSettings()
const size = createSizing()
const view = session.layout.view
const reviewOpen = createMemo(() => session.isDesktop() && session.layout.view().reviewPanel.opened())
const reviewPanelOpen = createMemo(() => reviewOpen() && !!session.identity.params.id)
const terminalOpen = createMemo(() => session.layout.view().terminal.opened())
@@ -42,7 +43,7 @@ export function createSessionScreenLayout(session: SessionModel) {
const splitReview = createMemo(() => reviewPanelOpen() && layout.review.diffStyle() === "split")
const resizedWidth = createMemo(() =>
clampSessionPanelWidth({
width: layout.session.width(),
width: view().reviewPanel.width(),
available: available(),
split: splitReview(),
}),
@@ -72,7 +73,7 @@ export function createSessionScreenLayout(session: SessionModel) {
}, panelLayout().stacked)
const sideRegionOpen = createMemo(() => reviewPanelOpen() || fileTreeOpen())
const terminalPane = createMemo(() =>
Math.min(layout.terminal.height(), typeof window === "undefined" ? 600 : window.innerHeight * 0.6),
Math.min(view().terminal.height(), typeof window === "undefined" ? 600 : window.innerHeight * 0.6),
)
const terminalPaneHeight = createMemo(() => `${terminalPane()}px`)
const sideHeight = createMemo(() => rowSize.height)
+57 -26
View File
@@ -11,10 +11,8 @@ import {
on,
} from "solid-js"
import { createStore } from "solid-js/store"
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 { MessageTimeline, SessionSummaryPanel } from "@/session/timeline/message-timeline"
import { useServer } from "@/runtime/server/current"
import { projectForSession } from "@/shell/layout/helpers"
@@ -31,6 +29,7 @@ import { SessionContextTab } from "./files/session-context-tab"
import { createSessionTimelineInteraction } from "./timeline/interaction"
import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./composer/region"
import { SessionIdentityHeader } from "./session-identity-header"
import { createAnimatedPresence } from "@/runtime/animated-presence"
const SessionMobileFiles = lazy(async () => {
const { SessionMobileFiles } = await import("./files/session-mobile-files")
@@ -39,7 +38,6 @@ const SessionMobileFiles = lazy(async () => {
export function SessionScreen(props: { session: SessionModel }) {
const session = props.session
const layout = useLayout()
const server = useServer()
const detailsProject = createMemo(() => {
const info = session.data.info()
@@ -66,14 +64,40 @@ export function SessionScreen(props: { session: SessionModel }) {
const sideVisible = createMemo(() => isDesktop() && screen.side.layout().visible)
const sideTerminalVisible = createMemo(() => isDesktop() && screen.terminal.side() && screen.terminal.open())
const bottomTerminalVisible = createMemo(() => isDesktop() && screen.terminal.open() && screen.terminal.bottom())
const sidePresence = createPresence({
show: sideVisible,
element: () => elements.side ?? null,
})
const bottomTerminalPresence = createPresence({
show: bottomTerminalVisible,
element: () => elements.bottomTerminal ?? null,
const sidePresence = createAnimatedPresence(
() => sideVisible() || undefined,
() => elements.side ?? null,
session.layout.tabKey,
)
const bottomTerminalPresence = createAnimatedPresence(
() => bottomTerminalVisible() || undefined,
() => elements.bottomTerminal ?? null,
session.layout.tabKey,
)
const sideMotion = createMemo<{
key?: string
region: boolean
terminal: boolean
animateRegion: boolean
animateTerminal: boolean
}>((previous) => {
const key = session.layout.tabKey()
const region = screen.side.region.open()
const terminal = sideTerminalVisible()
const sameTab = previous?.key === key
return {
key,
region,
terminal,
animateRegion: !!previous && sameTab && previous.region !== region,
animateTerminal: !!previous && sameTab && previous.terminal !== terminal,
}
})
const paneAnimating = () =>
sidePresence.animate() ||
sideMotion().animateRegion ||
sideMotion().animateTerminal ||
bottomTerminalPresence.animate()
createEffect(() => {
if (sideTerminalVisible()) setStore("sideTerminalPresent", true)
if (bottomTerminalVisible()) setStore("bottomTerminalCached", true)
@@ -256,7 +280,8 @@ export function SessionScreen(props: { session: SessionModel }) {
classList={{
"@container relative z-10 shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]": true,
"duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
!screen.size.active(),
!screen.size.active() && sidePresence.animate(),
"transition-none": screen.size.active() || !sidePresence.animate(),
}}
data-slot="session-chat-panel"
style={{
@@ -279,18 +304,18 @@ export function SessionScreen(props: { session: SessionModel }) {
max={screen.panel.max()}
onResize={(width) => {
screen.size.touch()
layout.session.resize(width)
session.layout.view().reviewPanel.resize(width)
}}
/>
</div>
</Show>
</div>
<Show when={sidePresence.present() || store.sideTerminalPresent}>
<Show when={sidePresence.present() || store.sideReviewPresent || store.sideTerminalPresent}>
<div
ref={(element) => setElements("side", element)}
data-slot="session-side-panel-presence"
data-opened={sideVisible()}
data-opened={sidePresence.animate() ? sidePresence.show() : undefined}
onAnimationEnd={(event) => {
if (event.currentTarget !== event.target) return
if (event.animationName !== "terminal-panel-presence-in" || !sideVisible()) return
@@ -311,15 +336,15 @@ export function SessionScreen(props: { session: SessionModel }) {
data-slot="session-side-region"
classList={{
"absolute inset-x-0 top-0 min-h-0 overflow-visible transition-[height] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none": true,
"will-change-[height]": !screen.size.active() && store.sideHeightMotion,
"transition-none": screen.size.active() || !store.sideHeightMotion,
"will-change-[height]": !screen.size.active() && store.sideHeightMotion && paneAnimating(),
"transition-none": screen.size.active() || !store.sideHeightMotion || !paneAnimating(),
}}
style={{ height: screen.side.region.height() }}
>
<Show when={store.sideRegionPresent}>
<div
data-slot="session-side-region-presence"
data-opened={screen.side.region.open()}
data-opened={sideMotion().animateRegion ? sideMotion().region : undefined}
class="absolute inset-0"
onAnimationEnd={(event) => {
if (event.currentTarget !== event.target) return
@@ -341,6 +366,7 @@ export function SessionScreen(props: { session: SessionModel }) {
"relative z-0 shrink-0 overflow-visible bg-v2-background-bg-deep transition-[height] duration-[40ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none": true,
"delay-0": !screen.side.gap.closing(),
"delay-[200ms]": screen.side.gap.closing(),
"transition-none": !paneAnimating(),
}}
style={{ height: screen.side.gap.height() }}
onPointerDown={() => screen.size.start()}
@@ -349,13 +375,13 @@ export function SessionScreen(props: { session: SessionModel }) {
<ResizeHandle
class="!relative !inset-auto !h-full !w-full !transform-none"
direction="vertical"
size={layout.terminal.height()}
size={session.layout.view().terminal.height()}
min={100}
max={typeof window === "undefined" ? 600 : window.innerHeight * 0.6}
collapseThreshold={50}
onResize={(height) => {
screen.size.touch()
layout.terminal.resize(height)
session.layout.view().terminal.resize(height)
}}
onCollapse={() => session.layout.view().terminal.close()}
/>
@@ -365,15 +391,15 @@ export function SessionScreen(props: { session: SessionModel }) {
data-slot="session-side-terminal-region"
classList={{
"relative z-10 min-h-0 shrink-0 overflow-visible transition-[height] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none": true,
"will-change-[height]": !screen.size.active() && store.sideHeightMotion,
"transition-none": screen.size.active() || !store.sideHeightMotion,
"will-change-[height]": !screen.size.active() && store.sideHeightMotion && paneAnimating(),
"transition-none": screen.size.active() || !store.sideHeightMotion || !paneAnimating(),
}}
style={{ height: screen.side.terminal.height() }}
>
<Show when={store.sideTerminalPresent}>
<div
data-slot="side-terminal-panel-presence"
data-opened={sideTerminalVisible()}
data-opened={sideMotion().animateTerminal ? sideMotion().terminal : undefined}
class="absolute inset-0 rounded-[10px] bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]"
>
<div data-slot="side-terminal-panel-clip" class="size-full overflow-clip rounded-[10px]">
@@ -381,6 +407,7 @@ export function SessionScreen(props: { session: SessionModel }) {
fill
framed={false}
present={store.sideTerminalPresent}
animate={sidePresence.animate() || sideMotion().animateTerminal}
contentHeight={screen.side.terminal.contentHeight()}
/>
</div>
@@ -397,7 +424,7 @@ export function SessionScreen(props: { session: SessionModel }) {
<div
ref={(element) => setElements("bottomTerminal", element)}
data-slot="terminal-panel-presence"
data-opened={bottomTerminalVisible()}
data-opened={bottomTerminalPresence.animate() ? bottomTerminalPresence.show() : undefined}
classList={{
hidden: !bottomTerminalPresence.present(),
"relative min-h-0 shrink-0": isDesktop(),
@@ -408,19 +435,23 @@ export function SessionScreen(props: { session: SessionModel }) {
<ResizeHandle
class="!relative !inset-auto !h-full !w-full !transform-none"
direction="vertical"
size={layout.terminal.height()}
size={session.layout.view().terminal.height()}
min={100}
max={typeof window === "undefined" ? 600 : window.innerHeight * 0.6}
collapseThreshold={50}
onResize={(height) => {
screen.size.touch()
layout.terminal.resize(height)
session.layout.view().terminal.resize(height)
}}
onCollapse={() => session.layout.view().terminal.close()}
/>
</div>
</Show>
<TerminalPanel stacked={isDesktop()} present={store.bottomTerminalCached} />
<TerminalPanel
stacked={isDesktop()}
present={store.bottomTerminalCached}
animate={bottomTerminalPresence.animate()}
/>
</div>
</Show>
</div>
+23 -1
View File
@@ -5,6 +5,8 @@ import { SessionRouteKey, SessionStateKey } from "@/runtime/server/scope"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServerSDK } from "@/runtime/server/client"
import { base64Encode } from "@opencode-ai/util/encode"
import { ServerConnection } from "@/runtime/server/registry"
import { findSessionTab, tabKey, useTabs } from "@/shell/tabs/tabs"
export const useSessionKey = () => {
const params = useParams()
@@ -19,12 +21,32 @@ export const useSessionKey = () => {
export const useSessionLayout = () => {
const layout = useLayout()
const tabs = useTabs()
const { params, sessionKey, workspaceKey } = useSessionKey()
const serverSDK = useServerSDK()
const currentTab = createMemo(() => {
if (!params.id) return
return findSessionTab(tabs.store, ServerConnection.key(serverSDK.server), params.id)
})
const panes = {
terminalOpened: () => tabs.pane(currentTab(), "terminal"),
setTerminalOpened: (opened: boolean) => tabs.setPane(currentTab(), "terminal", opened),
terminalHeight: () => tabs.paneSize(currentTab(), "terminalHeight"),
setTerminalHeight: (height: number) => tabs.setPaneSize(currentTab(), "terminalHeight", height),
reviewOpened: () => tabs.pane(currentTab(), "review"),
setReviewOpened: (opened: boolean) => tabs.setPane(currentTab(), "review", opened),
sessionWidth: () => tabs.paneSize(currentTab(), "sessionWidth"),
setSessionWidth: (width: number) => tabs.setPaneSize(currentTab(), "sessionWidth", width),
}
return {
params,
sessionKey,
workspaceKey,
tabKey: createMemo(() => {
const tab = currentTab()
return tab && tabKey(tab)
}),
tabs: createMemo(() => layout.tabs(sessionKey)),
view: createMemo(() => layout.view(sessionKey)),
view: createMemo(() => layout.view(sessionKey, panes)),
}
}
+4 -4
View File
@@ -17,7 +17,6 @@ import { SortableTerminalTab } from "@/session/terminal/tab"
import { Terminal } from "@/session/terminal/terminal"
import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { useLayout } from "@/shell/state/layout"
import { useTerminal, type LocalPTY } from "@/session/terminal/context"
import { useWorkspaceLocation } from "@/workspaces/location"
import { terminalTabLabel } from "@/session/terminal/terminal-label"
@@ -45,9 +44,9 @@ export function TerminalPanel(
present?: boolean
contentHeight?: string
embedded?: boolean
animate?: boolean
} = {},
) {
const layout = useLayout()
const terminal = useTerminal()
const sdk = useWorkspaceLocation()
const language = useLanguage()
@@ -57,7 +56,7 @@ export function TerminalPanel(
const isDesktop = createMediaQuery("(min-width: 768px)")
const opened = createMemo(() => view().terminal.opened())
const size = createSizing()
const height = createMemo(() => layout.terminal.height())
const height = createMemo(() => view().terminal.height())
const close = () => view().terminal.close()
let root: HTMLElement | undefined
let tabList: HTMLDivElement | undefined
@@ -238,10 +237,11 @@ export function TerminalPanel(
pane={pane()}
max={max()}
resizing={size.active()}
animate={props.animate}
onResizeStart={size.start}
onResize={(next) => {
size.touch()
layout.terminal.resize(next)
view().terminal.resize(next)
}}
onCollapse={close}
>
@@ -15,6 +15,7 @@ export function TerminalSurface(
pane: number
max: number
resizing: boolean
animate?: boolean
onResizeStart: () => void
onResize: (height: number) => void
onCollapse: () => void
@@ -27,7 +28,9 @@ export function TerminalSurface(
id="terminal-panel"
data-component="terminal-panel"
data-opened={props.opened}
data-size-animated={!props.embedded && !props.resizing && (!props.desktop || props.stacked)}
data-size-animated={
props.animate !== false && !props.embedded && !props.resizing && (!props.desktop || props.stacked)
}
role="region"
aria-label={props.label}
aria-hidden={!props.opened}
@@ -478,7 +478,11 @@ 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"
data-workspace-session={props.workspaceSession() ? "" : undefined}
data-local-session={!props.workspaceSession() ? "" : undefined}
>
<div
class="absolute left-1/2 -translate-x-1/2 z-[60] pointer-events-none transition-all duration-200 ease-out"
classList={{
+90
View File
@@ -0,0 +1,90 @@
[data-slot="mobile-drawer-overlay"] {
position: fixed;
inset: 0;
z-index: 50;
background: var(--v2-overlay-simple-overlay-scrim);
animation: mobile-drawer-backdrop-in 240ms ease-out;
}
[data-slot="mobile-drawer-overlay"]:is([data-closing], [data-closed]) {
animation: mobile-drawer-backdrop-out 200ms ease-in forwards;
}
[data-slot="mobile-drawer-content"] {
box-sizing: border-box;
position: fixed;
inset-inline: 0;
bottom: 0;
z-index: 51;
display: flex;
flex-direction: column;
max-height: min(75dvh, calc(100dvh - env(safe-area-inset-top, 0px) - 16px));
padding: 0 12px max(12px, env(safe-area-inset-bottom, 0px));
padding-left: max(12px, env(safe-area-inset-left, 0px));
padding-right: max(12px, env(safe-area-inset-right, 0px));
border-radius: 16px 16px 0 0;
background: var(--v2-background-bg-deep);
color: var(--v2-text-text-base);
box-shadow: var(--v2-elevation-overlay);
outline: none;
app-region: no-drag;
}
[data-slot="mobile-drawer-content"][data-transitioning] {
transition: transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
[data-slot="mobile-drawer-content"][data-closing] {
transition-duration: 200ms;
}
[data-slot="mobile-drawer-content"][data-closed] {
visibility: hidden;
pointer-events: none;
}
[data-slot="mobile-drawer-handle"] {
display: flex;
height: 28px;
flex-shrink: 0;
align-items: center;
justify-content: center;
touch-action: none;
}
[data-slot="mobile-drawer-handle"] span {
width: 32px;
height: 4px;
border-radius: 999px;
background: var(--v2-border-border-strong);
}
@keyframes mobile-drawer-backdrop-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes mobile-drawer-backdrop-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
[data-slot="mobile-drawer-content"][data-transitioning],
[data-slot="mobile-drawer-content"][data-closing] {
transition: none;
}
[data-slot="mobile-drawer-overlay"],
[data-slot="mobile-drawer-overlay"]:is([data-closing], [data-closed]) {
animation: none;
}
}
+47
View File
@@ -0,0 +1,47 @@
import Drawer from "@corvu/drawer"
import type { ParentProps } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import "./mobile-drawer.css"
export function MobileDrawer(
props: ParentProps<{
open: boolean
onOpenChange: (open: boolean) => void
onContentPresentChange?: (present: boolean) => void
returnFocus?: () => HTMLElement | undefined
closeOnOutsideFocus?: boolean
}>,
) {
return (
<Drawer
open={props.open}
onOpenChange={props.onOpenChange}
onContentPresentChange={props.onContentPresentChange}
side="bottom"
finalFocusEl={props.returnFocus?.()}
closeOnOutsideFocus={props.closeOnOutsideFocus}
>
{props.children}
</Drawer>
)
}
export const MobileDrawerTrigger = Drawer.Trigger
export function MobileDrawerContent(props: ParentProps) {
const language = useLanguage()
return (
<Drawer.Portal forceMount>
<Drawer.Overlay data-slot="mobile-drawer-overlay" />
<Drawer.Content forceMount data-slot="mobile-drawer-content" dir={language.direction()}>
<div data-slot="mobile-drawer-handle" aria-hidden="true">
<span />
</div>
{props.children}
</Drawer.Content>
</Drawer.Portal>
)
}
export const MobileDrawerLabel = Drawer.Label
export const MobileDrawerClose = Drawer.Close
@@ -0,0 +1,34 @@
[data-slot="mobile-panel"] {
display: flex;
min-height: 0;
flex-direction: column;
}
[data-slot="mobile-panel-header"] {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-inline-start: 8px;
padding-block-end: 8px;
}
[data-slot="mobile-panel-header"] h2 {
margin: 0;
font-size: 14px;
font-weight: 530;
line-height: var(--line-height-base);
}
[data-slot="mobile-panel-close"][data-component="button-v2"] {
height: 44px;
flex-shrink: 0;
}
[data-slot="mobile-panel-content"] {
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
touch-action: pan-y;
}
+20 -22
View File
@@ -1,7 +1,8 @@
import Drawer from "@corvu/drawer"
import type { ParentProps } from "solid-js"
import { Button } from "@opencode-ai/ui/button"
import { useLanguage } from "@/runtime/i18n/language"
import "./status/status-drawer.css"
import { MobileDrawer, MobileDrawerClose, MobileDrawerContent, MobileDrawerLabel } from "./mobile-drawer"
import "./mobile-panel-drawer.css"
export function MobilePanelDrawer(
props: ParentProps<{
@@ -13,32 +14,29 @@ export function MobilePanelDrawer(
) {
const language = useLanguage()
return (
<Drawer
<MobileDrawer
open={props.open}
onOpenChange={props.onOpenChange}
side="bottom"
finalFocusEl={props.returnFocus?.()}
returnFocus={props.returnFocus}
// Menu focus handoff must not dismiss the drawer during its opening transition.
closeOnOutsideFocus={false}
>
{/* Preserve Corvu's content and dismissal lifecycle across reopenings. */}
<Drawer.Portal forceMount>
<Drawer.Overlay data-slot="mobile-status-overlay" />
<Drawer.Content forceMount data-slot="mobile-status-drawer" dir={language.direction()}>
<div data-slot="mobile-status-drag-handle" aria-hidden="true">
<span />
</div>
<div data-slot="mobile-status-header" data-corvu-no-drag>
<Drawer.Label>{props.title}</Drawer.Label>
<Drawer.Close data-slot="mobile-status-close" aria-label={language.t("common.close")}>
<MobileDrawerContent>
<div data-slot="mobile-panel" data-corvu-no-drag>
<div data-slot="mobile-panel-header">
<MobileDrawerLabel>{props.title}</MobileDrawerLabel>
<MobileDrawerClose
as={Button}
variant="ghost"
data-slot="mobile-panel-close"
aria-label={language.t("common.close")}
>
{language.t("common.close")}
</Drawer.Close>
</MobileDrawerClose>
</div>
<div data-slot="mobile-status-content" data-corvu-no-drag>
{props.children}
</div>
</Drawer.Content>
</Drawer.Portal>
</Drawer>
<div data-slot="mobile-panel-content">{props.children}</div>
</div>
</MobileDrawerContent>
</MobileDrawer>
)
}
+49 -3
View File
@@ -59,6 +59,16 @@ export type HomeProjectSelection = { server: ServerConnection.Key; directory?: s
export type ReviewDiffStyle = "unified" | "split"
export type ReviewChangeMode = "git" | "branch" | "turn"
export type ReviewPanelSource = "context-button" | "other"
export type TabPanes = {
terminalOpened: Accessor<boolean>
setTerminalOpened(opened: boolean): void
terminalHeight: Accessor<number | undefined>
setTerminalHeight(height: number): void
reviewOpened: Accessor<boolean>
setReviewOpened(opened: boolean): void
sessionWidth: Accessor<number | undefined>
setSessionWidth(width: number): void
}
export type LayoutRoute =
| { type: "home" }
@@ -508,7 +518,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
return message
},
},
view(sessionKey: string | Accessor<string>) {
view(sessionKey: string | Accessor<string>, panes?: TabPanes) {
const key = createSessionKeyReader(sessionKey, ensureKey)
const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} })
const reviewMode = createMemo(() => {
@@ -519,11 +529,24 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const file = s().reviewFile
if (typeof file === "string") return file
})
const terminalOpened = createMemo(() => store.terminal?.opened ?? false)
const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED)
const terminalOpened = panes?.terminalOpened ?? createMemo(() => store.terminal?.opened ?? false)
const terminalHeight = createMemo(() =>
panes
? (panes.terminalHeight() ?? DEFAULT_TERMINAL_HEIGHT)
: (store.terminal?.height ?? DEFAULT_TERMINAL_HEIGHT),
)
const reviewPanelOpened =
panes?.reviewOpened ?? createMemo(() => store.review?.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED)
const sessionWidth = createMemo(() =>
panes ? (panes.sessionWidth() ?? DEFAULT_SESSION_WIDTH) : store.session.width,
)
const reviewPanelSource = createMemo(() => (reviewPanelOpened() ? ephemeral.reviewPanelSource : "other"))
function setTerminalOpened(next: boolean) {
if (panes) {
panes.setTerminalOpened(next)
return
}
const current = store.terminal
if (!current) {
setStore("terminal", { height: DEFAULT_TERMINAL_HEIGHT, opened: next })
@@ -537,6 +560,13 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
function setReviewPanelOpened(next: boolean, source: ReviewPanelSource) {
const nextSource = next ? source : "other"
if (panes) {
batch(() => {
panes.setReviewOpened(next)
setEphemeral("reviewPanelSource", nextSource)
})
return
}
const current = store.review
if (!current) {
batch(() => {
@@ -566,6 +596,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
terminal: {
opened: terminalOpened,
height: terminalHeight,
resize(height: number) {
if (panes) {
panes.setTerminalHeight(height)
return
}
setStore("terminal", "height", height)
},
open() {
setTerminalOpened(true)
},
@@ -579,6 +617,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
reviewPanel: {
opened: reviewPanelOpened,
source: reviewPanelSource,
width: sessionWidth,
resize(width: number) {
if (panes) {
panes.setSessionWidth(width)
return
}
setStore("session", "width", width)
},
open(source: ReviewPanelSource = "other") {
setReviewPanelOpened(true, source)
},
@@ -1,109 +1,3 @@
[data-slot="mobile-status-overlay"] {
position: fixed;
inset: 0;
z-index: 50;
background: var(--v2-overlay-simple-overlay-scrim);
animation: mobile-status-backdrop-in 240ms ease-out;
}
[data-slot="mobile-status-overlay"]:is([data-closing], [data-closed]) {
animation: mobile-status-backdrop-out 200ms ease-in forwards;
}
[data-slot="mobile-status-drawer"] {
box-sizing: border-box;
position: fixed;
inset-inline: 0;
bottom: 0;
z-index: 51;
display: flex;
flex-direction: column;
max-height: min(75dvh, calc(100dvh - env(safe-area-inset-top, 0px) - 16px));
padding: 0 12px max(12px, env(safe-area-inset-bottom, 0px));
padding-left: max(12px, env(safe-area-inset-left, 0px));
padding-right: max(12px, env(safe-area-inset-right, 0px));
border-radius: 16px 16px 0 0;
background: var(--v2-background-bg-deep);
color: var(--v2-text-text-base);
box-shadow: var(--v2-elevation-overlay);
outline: none;
app-region: no-drag;
}
[data-slot="mobile-status-drawer"][data-transitioning] {
transition: transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
[data-slot="mobile-status-drawer"][data-closing] {
transition-duration: 200ms;
}
[data-slot="mobile-status-drawer"][data-closed] {
visibility: hidden;
pointer-events: none;
}
[data-slot="mobile-status-drag-handle"] {
display: flex;
height: 28px;
flex-shrink: 0;
align-items: center;
justify-content: center;
touch-action: none;
}
[data-slot="mobile-status-drag-handle"] span {
width: 32px;
height: 4px;
border-radius: 999px;
background: var(--v2-border-border-strong);
}
[data-slot="mobile-status-header"] {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-inline-start: 8px;
padding-block-end: 8px;
}
[data-slot="mobile-status-header"] h2 {
margin: 0;
font-size: 14px;
font-weight: 530;
line-height: var(--line-height-base);
}
[data-slot="mobile-status-close"] {
min-height: 44px;
flex-shrink: 0;
padding-inline: 12px;
border-radius: 6px;
color: var(--v2-text-text-base);
font-size: 13px;
line-height: var(--line-height-compact);
}
@media (hover: hover) {
[data-slot="mobile-status-close"]:hover {
background: var(--v2-overlay-simple-overlay-hover);
}
}
[data-slot="mobile-status-close"]:focus-visible {
outline: 2px solid var(--v2-border-border-focus);
outline-offset: -2px;
}
[data-slot="mobile-status-content"] {
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
touch-action: pan-y;
}
[data-slot="mobile-status-loading"] {
display: flex;
min-height: 56px;
@@ -113,33 +7,3 @@
font-size: 13px;
line-height: var(--line-height-base);
}
@keyframes mobile-status-backdrop-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes mobile-status-backdrop-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
[data-slot="mobile-status-drawer"][data-transitioning],
[data-slot="mobile-status-drawer"][data-closing] {
transition: none;
}
[data-slot="mobile-status-overlay"],
[data-slot="mobile-status-overlay"]:is([data-closing], [data-closed]) {
animation: none;
}
}
@@ -1,6 +1,7 @@
import { lazy, Suspense } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { MobilePanelDrawer } from "../mobile-panel-drawer"
import "./status-drawer.css"
const Body = lazy(async () => {
const { StatusPopoverBody } = await import("./body")
+6 -2
View File
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import { createRoot, getOwner, onCleanup } from "solid-js"
import { createTabMemory } from "./memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed"
import { sessionIDHasOpenTab, tabHref, tabKey, type SessionTab, type Tab } from "./tabs"
import { findSessionTab, sessionIDHasOpenTab, tabHref, tabKey, type SessionTab, type Tab } from "./tabs"
import { migrateTabs } from "./migration"
import type { ServerConnection } from "@/runtime/server/registry"
@@ -48,8 +48,12 @@ test("session tab identity stays rooted while its href follows the child route",
})
test("finds open root and routed session tabs", () => {
const tabs = [{ ...sessionTab("root"), routeSessionId: "child" }]
const tab = { ...sessionTab("root"), routeSessionId: "child" }
const tabs = [tab]
expect(findSessionTab(tabs, server, "root")).toBe(tab)
expect(findSessionTab(tabs, server, "child")).toBe(tab)
expect(findSessionTab(tabs, server, "closed")).toBeUndefined()
expect(sessionIDHasOpenTab(tabs, server, "root")).toBe(true)
expect(sessionIDHasOpenTab(tabs, server, "child")).toBe(true)
expect(sessionIDHasOpenTab(tabs, server, "closed")).toBe(false)
+60 -3
View File
@@ -46,6 +46,10 @@ export type TabInfo = {
directory?: string
}
export type TabPane = "terminal" | "review"
export type TabPaneSize = "terminalHeight" | "sessionWidth"
type TabPaneState = Partial<Record<TabPane, boolean> & Record<TabPaneSize, number>>
type RecentTab = {
key?: string
}
@@ -62,8 +66,8 @@ export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, ses
return sessionIDHasOpenTab(tabs, server, session.id)
}
export function sessionIDHasOpenTab(tabs: Tab[], server: ServerConnection.Key, sessionID: string) {
return tabs.some(
export function findSessionTab(tabs: Tab[], server: ServerConnection.Key, sessionID: string) {
return tabs.find(
(tab) =>
tab.type === "session" &&
tab.server === server &&
@@ -71,6 +75,10 @@ export function sessionIDHasOpenTab(tabs: Tab[], server: ServerConnection.Key, s
)
}
export function sessionIDHasOpenTab(tabs: Tab[], server: ServerConnection.Key, sessionID: string) {
return !!findSessionTab(tabs, server, sessionID)
}
export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
name: "Tabs",
gate: false,
@@ -89,6 +97,10 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
Persist.window("tabs.info"),
createStore<Record<string, TabInfo>>({}),
)
const [panes, setPanes, , panesReady] = persisted(
Persist.window("tabs.panes"),
createStore<Record<string, TabPaneState>>({}),
)
const [closed, setClosed, , closedReady] = persisted(Persist.window("tabs.closed"), createStore<ClosedTab[]>([]))
const [pending, setPending] = createStore<Record<string, PendingSession | undefined>>({})
@@ -141,6 +153,15 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
)
}
const removePanes = (key: string) => {
if (!panes[key]) return
setPanes(
produce((draft) => {
delete draft[key]
}),
)
}
onCleanup(memory.dispose)
createEffect(() => {
@@ -153,6 +174,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const key = tabKey(tab)
memory.remove(key)
removeInfo(key)
removePanes(key)
}
}
setStore(() => next)
@@ -162,6 +184,10 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
for (const key of Object.keys(info)) {
if (!keys.has(key)) removeInfo(key)
}
if (!panesReady()) return
for (const key of Object.keys(panes)) {
if (!keys.has(key)) removePanes(key)
}
})
createEffect(() => {
@@ -198,6 +224,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
}).finally(() => closing.delete(key))
memory.remove(key)
removeInfo(key)
removePanes(key)
if (draftID) removeDraftPersisted(draftID)
}
@@ -491,8 +518,38 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
stateValue<T>(tab: Tab, name: string) {
return memory.get<T>(tabKey(tab), name)
},
pane(tab: Tab | undefined, pane: TabPane) {
if (!tab) return false
return panes[tabKey(tab)]?.[pane] ?? false
},
setPane(tab: Tab | undefined, pane: TabPane, opened: boolean) {
if (!tab) return
const key = tabKey(tab)
const current = panes[key]
if (current?.[pane] === opened) return
if (!current) {
setPanes(key, { [pane]: opened })
return
}
setPanes(key, pane, opened)
},
paneSize(tab: Tab | undefined, size: TabPaneSize) {
if (!tab) return
return panes[tabKey(tab)]?.[size]
},
setPaneSize(tab: Tab | undefined, size: TabPaneSize, value: number) {
if (!tab) return
const key = tabKey(tab)
const current = panes[key]
if (current?.[size] === value) return
if (!current) {
setPanes(key, { [size]: value })
return
}
setPanes(key, size, value)
},
}
return { ...actions, store, info, ready, infoReady, recentReady }
return { ...actions, store, info, ready, infoReady, recentReady, panesReady }
},
})
+2 -82
View File
@@ -14,63 +14,13 @@
var(--v2-background-bg-layer-02);
}
[data-slot="mobile-tabs-overlay"] {
position: fixed;
inset: 0;
z-index: 50;
background: var(--v2-overlay-simple-overlay-scrim);
animation: mobile-tabs-backdrop-in 240ms ease-out;
}
[data-slot="mobile-tabs-overlay"]:is([data-closing], [data-closed]) {
animation: mobile-tabs-backdrop-out 200ms ease-in forwards;
}
/* Keep the strip mounted for tab shortcuts and session metadata while collapsed. */
[data-slot="mobile-tabs-drawer"] {
box-sizing: border-box;
position: fixed;
inset-inline: 0;
bottom: 0;
z-index: 51;
display: flex;
min-height: 0;
flex-direction: column;
gap: 8px;
max-height: min(75dvh, calc(100dvh - env(safe-area-inset-top, 0px) - 16px));
padding: 0 12px max(12px, env(safe-area-inset-bottom, 0px));
border-radius: 16px 16px 0 0;
background: var(--v2-background-bg-deep);
box-shadow: var(--v2-elevation-overlay);
outline: none;
}
[data-slot="mobile-tabs-drawer"][data-transitioning] {
transition: transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
[data-slot="mobile-tabs-drawer"][data-closing] {
transition-duration: 200ms;
}
[data-slot="mobile-tabs-drawer"][data-closed] {
visibility: hidden;
pointer-events: none;
}
[data-slot="mobile-tabs-drag-handle"] {
display: flex;
height: 28px;
flex-shrink: 0;
align-items: center;
justify-content: center;
touch-action: none;
}
[data-slot="mobile-tabs-drag-handle"] span {
width: 32px;
height: 4px;
border-radius: 999px;
background: var(--v2-border-border-strong);
margin-block-start: 8px;
}
[data-slot="mobile-tabs-drawer-list"] {
@@ -79,36 +29,6 @@
flex-direction: column;
}
@keyframes mobile-tabs-backdrop-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes mobile-tabs-backdrop-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
[data-slot="mobile-tabs-drawer"][data-transitioning],
[data-slot="mobile-tabs-drawer"][data-closing] {
transition: none;
}
[data-slot="mobile-tabs-overlay"],
[data-slot="mobile-tabs-overlay"]:is([data-closing], [data-closed]) {
animation: none;
}
}
[data-slot="mobile-tabs-drawer"] [data-slot="vertical-tabs"] {
display: flex;
flex-direction: column;
+12 -22
View File
@@ -25,7 +25,7 @@ import type { ComposerState } from "@/composer/persistence"
import "./titlebar.css"
import { newTabTooltipKeybind } from "@/shell/commands/tooltip-keybind"
import { TitlebarRightMount } from "@/shell/titlebar/right-slot"
import Drawer from "@corvu/drawer"
import { MobileDrawer, MobileDrawerContent, MobileDrawerLabel, MobileDrawerTrigger } from "@/shell/mobile-drawer"
import { sessionLabel } from "@/session/title"
import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
import { projectForSession } from "@/shell/layout/helpers"
@@ -415,7 +415,7 @@ export function Titlebar(props: {
<Show
when={!mobile()}
fallback={
<Drawer
<MobileDrawer
open={mobileTabs.open}
onOpenChange={(open) => setMobileTabs("open", open)}
onContentPresentChange={(present) => {
@@ -423,11 +423,9 @@ export function Titlebar(props: {
setMobileTabs("settings", false)
openSettings()
}}
side="bottom"
>
<Drawer.Trigger
<MobileDrawerTrigger
data-slot="mobile-tabs-trigger"
aria-expanded={mobileTabs.open}
class="flex h-7 min-w-0 flex-1 items-center gap-2 rounded-[6px] px-2 text-[13px] leading-4 text-v2-text-text-base focus-visible:outline-none [app-region:no-drag]"
aria-label={language.t("titlebar.tabs")}
>
@@ -467,15 +465,11 @@ export function Titlebar(props: {
{currentTitle()}
</span>
<span class="shrink-0 text-v2-text-text-muted">{tabsStore.length}</span>
</Drawer.Trigger>
<Drawer.Portal forceMount>
<Drawer.Overlay data-slot="mobile-tabs-overlay" />
<Drawer.Content forceMount data-slot="mobile-tabs-drawer" dir={language.direction()}>
<Drawer.Label class="sr-only">{language.t("titlebar.tabs")}</Drawer.Label>
<div data-slot="mobile-tabs-drag-handle" aria-hidden="true">
<span />
</div>
<div data-slot="mobile-tabs-drawer-list" data-corvu-no-drag>
</MobileDrawerTrigger>
<MobileDrawerContent>
<MobileDrawerLabel class="sr-only">{language.t("titlebar.tabs")}</MobileDrawerLabel>
<div data-slot="mobile-tabs-drawer" data-corvu-no-drag>
<div data-slot="mobile-tabs-drawer-list">
<TitlebarTabStrip
orientation="vertical"
tabs={tabsStore}
@@ -493,7 +487,6 @@ export function Titlebar(props: {
</div>
<button
type="button"
data-corvu-no-drag
data-action="mobile-tabs-new-session"
class="flex h-7 w-full shrink-0 items-center gap-2 rounded-[6px] px-2 text-[13px] leading-4 text-v2-text-text-base hover:bg-v2-background-bg-layer-02 focus-visible:outline-none focus-visible:bg-v2-background-bg-layer-02"
onClick={() => {
@@ -504,10 +497,7 @@ export function Titlebar(props: {
<Icon name="plus" />
{language.t("command.session.new")}
</button>
<div
class="flex shrink-0 flex-col gap-1 border-t border-v2-border-border-muted pt-2"
data-corvu-no-drag
>
<div class="flex shrink-0 flex-col gap-1 border-t border-v2-border-border-muted pt-2">
<button
type="button"
data-action="mobile-tabs-home"
@@ -546,9 +536,9 @@ export function Titlebar(props: {
</button>
</div>
</div>
</Drawer.Content>
</Drawer.Portal>
</Drawer>
</div>
</MobileDrawerContent>
</MobileDrawer>
}
>
<Show
@@ -1,6 +1,6 @@
import { expect, test } from "bun:test"
import { createAnimatedPresence } from "../src/runtime/animated-presence"
import { createRoot, createSignal } from "solid-js"
import { batch, createRoot, createSignal } from "solid-js"
test("animates visibility changes without animating initial presence", () => {
createRoot((dispose) => {
@@ -47,3 +47,22 @@ test("animates the first appearance when initially hidden", () => {
dispose()
})
})
test("does not animate visibility changes across identities", () => {
createRoot((dispose) => {
const [identity, setIdentity] = createSignal("a")
const [value, setValue] = createSignal<string | undefined>("visible")
const presence = createAnimatedPresence(value, () => null, identity)
expect(presence.animate()).toBe(false)
batch(() => {
setIdentity("b")
setValue(undefined)
})
expect(presence.animate()).toBe(false)
setValue("visible")
expect(presence.animate()).toBe(true)
dispose()
})
})
@@ -0,0 +1,79 @@
import { afterEach, beforeEach, expect, test } from "bun:test"
import { MemoryRouter, createMemoryHistory } from "@solidjs/router"
import { createComponent, render } from "solid-js/web"
import { isStandalone, PwaRoutePersistence, restorePwaRoute } from "../src/runtime/platform/pwa"
const key = "opencode.pwa.last-route"
const originalUrl = window.location.href
beforeEach(() => {
window.location.href = "http://localhost/"
})
afterEach(() => {
localStorage.removeItem(key)
window.location.href = originalUrl
})
test("normal browser windows are not standalone", () => {
expect(isStandalone()).toBe(false)
})
test("restores the last PWA route including query and hash without adding history", () => {
window.history.replaceState({ retained: true }, "", "http://localhost/")
const length = window.history.length
localStorage.setItem(key, "/server/local/session/session-1?view=files#file")
restorePwaRoute()
expect(window.location.pathname + window.location.search + window.location.hash).toBe(
"/server/local/session/session-1?view=files#file",
)
expect(window.history.length).toBe(length)
expect(window.history.state).toEqual({ retained: true })
})
test("preserves explicit launch routes, queries, and hashes", () => {
localStorage.setItem(key, "/server/local/session/saved")
for (const route of ["/server/local/session/linked", "/new-session?draftId=123", "/?launch=1", "/#launch"]) {
window.history.replaceState(null, "", `http://localhost${route}`)
restorePwaRoute()
expect(window.location.pathname + window.location.search + window.location.hash).toBe(route)
}
})
test("ignores missing, invalid, external, and auth-bearing saved routes", () => {
window.history.replaceState(null, "", "http://localhost/")
restorePwaRoute()
expect(window.location.pathname).toBe("/")
for (const value of [
"/removed-route",
"https://example.com/new-session",
"//example.com/new-session",
"http://[",
"/new-session?auth_token=secret",
]) {
localStorage.setItem(key, value)
restorePwaRoute()
expect(window.location.href).toBe("http://localhost/")
}
})
test("persists router navigation including returning home", async () => {
const host = document.createElement("div")
const history = createMemoryHistory()
history.set({ value: "/new-session?draftId=123", replace: true, scroll: false })
const dispose = render(() => createComponent(MemoryRouter, { history, root: PwaRoutePersistence }), host)
try {
expect(localStorage.getItem(key)).toBe("/new-session?draftId=123")
history.set({ value: "/server/local/session/next#file", scroll: false })
await new Promise((resolve) => setTimeout(resolve, 0))
expect(localStorage.getItem(key)).toBe("/server/local/session/next#file")
history.set({ value: "/", scroll: false })
await new Promise((resolve) => setTimeout(resolve, 0))
expect(localStorage.getItem(key)).toBe("/")
} finally {
dispose()
}
})
+1
View File
@@ -59,6 +59,7 @@ test.each(["dev", "beta", "prod"])("serves %s app icons", async (channel) => {
async function check(channel: string, read: (path: string) => Promise<Uint8Array>) {
const html = new TextDecoder().decode(await read("/index.html"))
const actual: typeof manifest = JSON.parse(new TextDecoder().decode(await read("/site.webmanifest")))
expect(actual.icons.every((icon) => icon.purpose === "maskable")).toBe(true)
expect(actual).toEqual({
...manifest,
icons: manifest.icons.map((icon) => ({ ...icon, src: `/icons/${channel}${icon.src}` })),
+15
View File
@@ -1,6 +1,7 @@
import { Argument, Flag, GlobalFlag } from "effect/unstable/cli"
import { Schema } from "effect"
import { Spec } from "../framework/spec"
import { Updater } from "../services/updater"
export const PrintLogs = GlobalFlag.setting("print-logs")({
flag: Flag.boolean("print-logs").pipe(
@@ -56,6 +57,20 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
prompt: Flag.string("prompt").pipe(Flag.withDescription("Prompt to use"), Flag.optional),
},
commands: [
Spec.make("upgrade", {
description: "Upgrade OpenCode to the latest or a specific version",
params: {
target: Argument.string("target").pipe(
Argument.withDescription("Version to upgrade to (with or without a leading v)"),
Argument.optional,
),
method: Flag.choice("method", Updater.methods).pipe(
Flag.withAlias("m"),
Flag.withDescription("Installation method to use"),
Flag.optional,
),
},
}),
Spec.make("acp", { description: "Start an Agent Client Protocol server" }),
Spec.make("api", {
description: "Make a request to the running server",
@@ -54,7 +54,7 @@ export function format(
plugin.state.status !== "active" || !plugin.features.tui
? []
: plugin.source.type === "package"
? [{ target: plugin.source.package, source: "advertised" as const }]
? [{ target: plugin.source.target, source: "advertised" as const }]
: plugin.source.type === "local"
? [{ target: path.dirname(plugin.source.path), source: "advertised" as const }]
: [],
@@ -73,7 +73,7 @@ export function format(
function name(plugin: PluginInfo) {
if (plugin.id) return plugin.id
if (plugin.source.type === "package") return plugin.source.package
if (plugin.source.type === "package") return plugin.source.target
if (plugin.source.type === "local") return plugin.source.path
return plugin.source.type
}
@@ -0,0 +1,38 @@
import { intro, log, outro, spinner } from "@clack/prompts"
import { Effect, Option } from "effect"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Updater } from "../../services/updater"
import { handlePromptErrors } from "../../ui/prompt"
import { OPENCODE_VERSION } from "../../version"
export default Runtime.handler(
Commands.commands.upgrade,
Effect.fn("cli.upgrade")(function* (input) {
intro("Upgrade")
const updater = yield* Updater.Service
const method = Option.getOrUndefined(input.method) ?? (yield* updater.method())
if (!method)
return yield* Effect.fail(
new Error("Could not detect the installation method. Pass --method to choose how to upgrade OpenCode."),
)
log.info(`Using method: ${method}`)
const target = Option.getOrUndefined(input.target) ?? (yield* updater.latest())
const version = target.trim().replace(/^v/, "")
if (version === OPENCODE_VERSION) {
log.warn(`OpenCode upgrade skipped: ${version} is already installed`)
outro("Done")
return
}
log.info(`From ${OPENCODE_VERSION}${version}`)
const progress = spinner()
progress.start("Upgrading...")
yield* updater.upgrade(method, target).pipe(
Effect.tap(() => Effect.sync(() => progress.stop("Upgrade complete"))),
Effect.tapCause(() => Effect.sync(() => progress.stop("Upgrade failed", 1))),
)
outro("Done")
}, handlePromptErrors),
)
+7 -5
View File
@@ -17,6 +17,7 @@ import { CpuProfile } from "./cpu-profile"
const Handlers = Runtime.handlers(Commands, {
$: () => import("./commands/handlers/default"),
upgrade: () => import("./commands/handlers/upgrade"),
acp: () => import("./commands/handlers/acp"),
api: () => import("./commands/handlers/api"),
auth: {
@@ -98,12 +99,13 @@ Effect.gen(function* () {
Effect.provide(Config.layer),
Effect.provide(Updater.layer),
Effect.provide(
LayerNode.compile(LayerNode.group([Global.node, AppProcess.node, Npm.node]), [
[
Global.node,
Global.layerWith(process.env.OPENCODE_CONFIG_DIR ? { config: process.env.OPENCODE_CONFIG_DIR } : {}),
LayerNode.compile(LayerNode.group([Global.node, AppProcess.node, Npm.node]), {
replacements: [
Global.node.replace(
Global.layerWith(process.env.OPENCODE_CONFIG_DIR ? { config: process.env.OPENCODE_CONFIG_DIR } : {}),
),
],
]),
}),
),
Effect.provide(
Observability.layer({
+2
View File
@@ -4,6 +4,7 @@ import fs from "node:fs"
import { readFile } from "node:fs/promises"
import path from "node:path"
import { ReadStream } from "node:tty"
import { OPENCODE_VERSION } from "./version"
export const INTERACTIVE_INPUT_ERROR = "opencode mini requires a controlling terminal for input"
@@ -137,6 +138,7 @@ export function createMiniHost(input: {
argv: process.argv.slice(2),
}
return {
version: OPENCODE_VERSION,
terminal: { stdin: input.terminal.stdin },
platform: process.platform,
stdout: {
+6 -5
View File
@@ -30,12 +30,13 @@ export const run = Effect.fnUntraced(function* (options: Options) {
return yield* processEffect(options).pipe(
Effect.provide(Updater.layer),
Effect.provide(
LayerNode.compile(LayerNode.group([Global.node, AppProcess.node]), [
[
Global.node,
Global.layerWith(process.env.OPENCODE_CONFIG_DIR ? { config: process.env.OPENCODE_CONFIG_DIR } : {}),
LayerNode.compile(LayerNode.group([Global.node, AppProcess.node]), {
replacements: [
Global.node.replace(
Global.layerWith(process.env.OPENCODE_CONFIG_DIR ? { config: process.env.OPENCODE_CONFIG_DIR } : {}),
),
],
]),
}),
),
Effect.provide(NodeServices.layer),
)
+1 -1
View File
@@ -15,7 +15,7 @@ export function action(current: string, latest: string, policy: Policy): Action
return policy === "notify" ? "notify" : "upgrade"
}
function parseReleaseVersion(input: string) {
export function parseReleaseVersion(input: string) {
if (input.length > 256) return
const match = input.trim().match(versionPattern)
if (!match) return

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