mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-25 01:51:43 +08:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a69f0ef2f8 | |||
| 0934b9e01d | |||
| 716393cdfa | |||
| 48aedea579 | |||
| 96c0f0d8c3 | |||
| 4b7d49c9a4 | |||
| 0e1081b0a5 | |||
| ce96bbe8a7 | |||
| 027f0e7cc9 | |||
| 0a442fa2f8 | |||
| 5a4914c670 | |||
| 3a797bf6e4 | |||
| 59d1ab783c | |||
| 6b4426bbd3 | |||
| 1b9f762ea5 | |||
| c8b4963d98 | |||
| 14a90331bc | |||
| cd2880075a | |||
| af22c16249 | |||
| bc4db825a4 | |||
| 4f0ba4f06e | |||
| 8dd92e2d95 | |||
| cb5a6c7db4 | |||
| eaa3dfe04e | |||
| 5fd800d169 |
@@ -54,6 +54,7 @@
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.18.15",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
"@dnd-kit/dom": "0.5.0",
|
||||
"@dnd-kit/helpers": "0.5.0",
|
||||
@@ -1642,6 +1643,10 @@
|
||||
|
||||
"@cloudflare/workers-types": ["@cloudflare/workers-types@4.20251008.0", "", {}, "sha512-dZLkO4PbCL0qcCSKzuW7KE4GYe49lI12LCfQ5y9XeSwgYBoAUbwH4gmJ6A0qUIURiTJTkGkRkhVPqpq2XNgYRA=="],
|
||||
|
||||
"@corvu/dialog": ["@corvu/dialog@0.2.4", "", { "dependencies": { "@corvu/utils": "~0.4.2", "solid-dismissible": "~0.1.1", "solid-focus-trap": "~0.1.8", "solid-presence": "~0.2.0", "solid-prevent-scroll": "~0.1.10" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-n54vJq+fOy8GVrnYBdJpD6JXNuyx7LOeMrRxwzAvZnYGpW8+AA12tnb/P/2emJj/HjOO5otheGKb0breshdFlA=="],
|
||||
|
||||
"@corvu/drawer": ["@corvu/drawer@0.2.4", "", { "dependencies": { "@corvu/dialog": "~0.2.4", "@corvu/utils": "~0.4.2", "@solid-primitives/memo": "^1.4.1", "solid-transition-size": "~0.1.4" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-7jQoGZ8ROB9CmXam2nMY2wEskU3IoFwZQywkF/7vrBc/edGsPv7mOVQ1GN6G+4nd7nrMZ3UtqHAhmRh9V0azlw=="],
|
||||
|
||||
"@corvu/utils": ["@corvu/utils@0.4.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.11" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-Ox2kYyxy7NoXdKWdHeDEjZxClwzO4SKM8plAaVwmAJPxHMqA0rLOoAsa+hBDwRLpctf+ZRnAd/ykguuJidnaTA=="],
|
||||
|
||||
"@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
|
||||
@@ -2876,6 +2881,8 @@
|
||||
|
||||
"@solid-primitives/media": ["@solid-primitives/media@2.3.6", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.6", "@solid-primitives/rootless": "^1.5.4", "@solid-primitives/static-store": "^0.1.4", "@solid-primitives/utils": "^6.4.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-pk49gPOq/UMRUJ+pTSrOfBiR8xJjRYHXIf1iR/jSnyQ/KroU+ZXhkZzavC7hvfp2vJeOTW5k2/HN0r3Q1VJ7Pw=="],
|
||||
|
||||
"@solid-primitives/memo": ["@solid-primitives/memo@1.5.1", "", { "dependencies": { "@solid-primitives/scheduled": "^1.5.3", "@solid-primitives/utils": "^6.4.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-VDPrkl9epp0tbby9MvsqphGFCYCtDRC5J8FKzTqHbQiG5hhR8n6xv4MfjhTW231IaBxxPHLxS43EE8c5Q23mSQ=="],
|
||||
|
||||
"@solid-primitives/props": ["@solid-primitives/props@3.2.4", "", { "dependencies": { "@solid-primitives/utils": "^6.4.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-MXXdvi2TSB6d+0N6ueA/HP1j/Kh9SEc4WdF1ZDMLwPagxW6pIHHSS9xbRO0RjqSVpNP4j0nxCWT1hx3CkJNhNA=="],
|
||||
|
||||
"@solid-primitives/refs": ["@solid-primitives/refs@1.1.4", "", { "dependencies": { "@solid-primitives/utils": "^6.4.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-bLjwIs6ZPu8NQnuw04sU3Zc8qKSpbc0umUU/O4SHf6oWOdO4+dHY8vb1T7C4b/Tg103+9WGr6sEE0+NFlbaB/A=="],
|
||||
@@ -5302,6 +5309,10 @@
|
||||
|
||||
"socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="],
|
||||
|
||||
"solid-dismissible": ["solid-dismissible@0.1.1", "", { "dependencies": { "@corvu/utils": "~0.4.1" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-9kcKBJIMdS+586cA1g63HYWxKh3h89leeNHbPZ1csYjuni+NvPBtNr11l0iEX2AKKEt6FHk6qNhc/gjoYAW1pA=="],
|
||||
|
||||
"solid-focus-trap": ["solid-focus-trap@0.1.9", "", { "dependencies": { "@corvu/utils": "~0.4.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-LTyNki6GUJPRLXV5uMWPkYClB07SUMubbr2EkAddiR0CJCF/I283txilMU9RURSr/P8EewMfXWu2o3aWrK7A5A=="],
|
||||
|
||||
"solid-js": ["solid-js@1.9.15", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.4", "seroval-plugins": "~1.5.4" } }, "sha512-EeiY2xfpZJqPLjXspVEKjAII4yv8NyG//NxZ3IpOFHdUNnnTyL0uJOeS9LWGvA7cFCz5y94cjFwYlmw5Luncsg=="],
|
||||
|
||||
"solid-list": ["solid-list@0.3.0", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-t4hx/F/l8Vmq+ib9HtZYl7Z9F1eKxq3eKJTXlvcm7P7yI4Z8O7QSOOEVHb/K6DD7M0RxzVRobK/BS5aSfLRwKg=="],
|
||||
@@ -5316,6 +5327,8 @@
|
||||
|
||||
"solid-stripe": ["solid-stripe@0.8.1", "", { "peerDependencies": { "@stripe/stripe-js": ">=1.44.1 <8.0.0", "solid-js": "^1.6.0" } }, "sha512-l2SkWoe51rsvk9u1ILBRWyCHODZebChSGMR6zHYJTivTRC0XWrRnNNKs5x1PYXsaIU71KYI6ov5CZB5cOtGLWw=="],
|
||||
|
||||
"solid-transition-size": ["solid-transition-size@0.1.4", "", { "dependencies": { "@corvu/utils": "~0.3.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-ocHVnbfy23CgfaH4cEUR/AFg0Y3CEL8Oh3n9Qv8OHFJgPh+zkmERKZQfi/xH5XvxDCizg8VjPrVUhiHB1Gza8g=="],
|
||||
|
||||
"solid-use": ["solid-use@0.9.1", "", { "peerDependencies": { "solid-js": "^1.7" } }, "sha512-UwvXDVPlrrbj/9ewG9ys5uL2IO4jSiwys2KPzK4zsnAcmEl7iDafZWW1Mo4BSEWOmQCGK6IvpmGHo1aou8iOFw=="],
|
||||
|
||||
"sort-keys": ["sort-keys@1.1.2", "", { "dependencies": { "is-plain-obj": "^1.0.0" } }, "sha512-vzn8aSqKgytVik0iwdBEi+zevbTYZogewTUM6dtpmGwEcdzbub/TX4bCzRhebDCRC3QzXgJsLRKB2V/Oof7HXg=="],
|
||||
@@ -6630,6 +6643,8 @@
|
||||
|
||||
"slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="],
|
||||
|
||||
"solid-transition-size/@corvu/utils": ["@corvu/utils@0.3.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.7" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-ZWlyWEE8qV9+CB9OAyo2bTrZGXQN9ZeM+JfYv89zoR+lRACKTDuoOZEdiyL8Uc7U5dUSH1uTqKhTTnaHWb+wZA=="],
|
||||
|
||||
"sort-keys/is-plain-obj": ["is-plain-obj@1.1.0", "", {}, "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg=="],
|
||||
|
||||
"source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
@@ -249,6 +249,107 @@ The published legacy `Service`, `layer`, `clientLayer`, and module-level control
|
||||
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.
|
||||
|
||||
```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.
|
||||
|
||||
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).
|
||||
|
||||
@@ -40,6 +40,7 @@ const RESPECTS_INLINE_HINTS = new Set([
|
||||
"anthropic-messages",
|
||||
"google-vertex-messages",
|
||||
"bedrock-converse",
|
||||
"bedrock-messages",
|
||||
"openrouter",
|
||||
])
|
||||
|
||||
|
||||
@@ -6,8 +6,12 @@ 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,
|
||||
@@ -15,7 +19,6 @@ import {
|
||||
type FinishReasonDetails,
|
||||
type FinishReason,
|
||||
type JsonSchema,
|
||||
type LLMRequest,
|
||||
type MediaPart,
|
||||
type ProviderMetadata,
|
||||
type ToolCallPart,
|
||||
@@ -61,6 +64,7 @@ export type ThinkingInput =
|
||||
))
|
||||
|
||||
export interface OptionsInput {
|
||||
readonly contextManagement?: ContextManagement
|
||||
readonly [key: string]: unknown
|
||||
readonly thinking?: ThinkingInput
|
||||
readonly effort?: string
|
||||
@@ -89,6 +93,23 @@ 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
|
||||
// =============================================================================
|
||||
@@ -236,7 +257,12 @@ 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,
|
||||
@@ -312,6 +338,18 @@ 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),
|
||||
@@ -335,7 +373,7 @@ const AnthropicBodyFields = {
|
||||
export const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields)
|
||||
export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesBody>
|
||||
|
||||
const AnthropicUsage = Schema.StructWithRest(
|
||||
const AnthropicIterationUsage = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
input_tokens: optionalNull(Schema.Number),
|
||||
output_tokens: Schema.optional(Schema.Number),
|
||||
@@ -354,6 +392,13 @@ const AnthropicUsage = 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({
|
||||
@@ -377,6 +422,7 @@ 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),
|
||||
@@ -406,6 +452,8 @@ 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>>
|
||||
@@ -848,6 +896,12 @@ 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) })
|
||||
@@ -1003,6 +1057,9 @@ 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 →
|
||||
@@ -1037,7 +1094,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
)
|
||||
}
|
||||
const options = yield* resolveOptions(request)
|
||||
return {
|
||||
const body = {
|
||||
model: request.model.id,
|
||||
system,
|
||||
messages,
|
||||
@@ -1058,6 +1115,18 @@ 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,
|
||||
})),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
@@ -1079,18 +1148,31 @@ 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 nonCached = usage.input_tokens ?? undefined
|
||||
const cacheRead = usage.cache_read_input_tokens ?? undefined
|
||||
const cacheWrite = usage.cache_creation_input_tokens ?? 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 inputTokens = ProviderShared.sumTokens(nonCached, cacheRead, cacheWrite)
|
||||
const outputTokens = ProviderShared.sumTokens(...iterations.map((item) => item.output_tokens))
|
||||
return new Usage({
|
||||
inputTokens,
|
||||
outputTokens: usage.output_tokens,
|
||||
outputTokens,
|
||||
contextTokens:
|
||||
last?.type === "message"
|
||||
? ProviderShared.sumTokens(
|
||||
last.input_tokens ?? undefined,
|
||||
last.cache_read_input_tokens ?? undefined,
|
||||
last.cache_creation_input_tokens ?? undefined,
|
||||
)
|
||||
: undefined,
|
||||
nonCachedInputTokens: nonCached,
|
||||
cacheReadInputTokens: cacheRead,
|
||||
cacheWriteInputTokens: cacheWrite,
|
||||
reasoningTokens: usage.output_tokens_details?.thinking_tokens,
|
||||
totalTokens: ProviderShared.totalTokens(inputTokens, usage.output_tokens, undefined),
|
||||
reasoningTokens: ProviderShared.sumTokens(...iterations.map((item) => item.output_tokens_details?.thinking_tokens)),
|
||||
totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined),
|
||||
providerMetadata: { [providerMetadataKey]: usage },
|
||||
})
|
||||
}
|
||||
@@ -1112,6 +1194,7 @@ const mergeUsage = (left: Usage | undefined, right: Usage | undefined, providerM
|
||||
return new Usage({
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
contextTokens: right.contextTokens ?? left.contextTokens,
|
||||
nonCachedInputTokens,
|
||||
cacheReadInputTokens,
|
||||
cacheWriteInputTokens,
|
||||
@@ -1170,7 +1253,6 @@ 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]
|
||||
@@ -1265,7 +1347,16 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
|
||||
) {
|
||||
const delta = event.delta
|
||||
|
||||
if (delta?.type === "text_delta" && delta.text) {
|
||||
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 (!state.lifecycle.text.has(`text-${event.index ?? 0}`)) return [state, NO_EVENTS] satisfies StepResult
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
@@ -1274,7 +1365,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 [
|
||||
@@ -1286,7 +1377,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 [
|
||||
@@ -1298,7 +1389,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(
|
||||
@@ -1323,6 +1414,18 @@ 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 ?? []
|
||||
@@ -1374,6 +1477,8 @@ 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
|
||||
@@ -1418,16 +1523,21 @@ const onError = (event: AnthropicEvent) => {
|
||||
)
|
||||
}
|
||||
|
||||
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 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 invalidStreamEvent = (event: AnthropicEvent) =>
|
||||
Effect.fail(
|
||||
@@ -1456,7 +1566,16 @@ 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 (!isKnownStreamBlockType(event.content_block.type)) return Effect.succeed<StepResult>([state, NO_EVENTS])
|
||||
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])
|
||||
const decoded = decodeAnthropicStreamBlock(event.content_block)
|
||||
if (Option.isNone(decoded)) return invalidStreamEvent(event)
|
||||
const block = decoded.value
|
||||
@@ -1470,7 +1589,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" && !isKnownStreamDeltaType(event.delta.type))
|
||||
if (typeof event.delta.type === "string" && !STREAM_DELTA_TYPES.has(event.delta.type))
|
||||
return Effect.succeed<StepResult>([state, NO_EVENTS])
|
||||
const decoded = decodeAnthropicStreamDelta(event.delta)
|
||||
if (Option.isNone(decoded)) return invalidStreamEvent(event)
|
||||
@@ -1504,6 +1623,8 @@ 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: {},
|
||||
@@ -1513,6 +1634,37 @@ 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",
|
||||
@@ -1522,7 +1674,7 @@ export const route = Route.make({
|
||||
baseURL: DEFAULT_BASE_URL,
|
||||
}),
|
||||
auth: Auth.none,
|
||||
framing,
|
||||
transport: transport<AnthropicMessagesBody>(),
|
||||
headers: () => ({ "anthropic-version": "2023-06-01" }),
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
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)),
|
||||
})
|
||||
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)
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
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"
|
||||
@@ -1,5 +1,6 @@
|
||||
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"
|
||||
|
||||
@@ -32,13 +32,14 @@ export const PATH = "/responses"
|
||||
// =============================================================================
|
||||
// Request Body Schema
|
||||
// =============================================================================
|
||||
const OpenResponsesInputText = Schema.Struct({
|
||||
export const OpenResponsesInputText = Schema.Struct({
|
||||
type: Schema.tag("input_text"),
|
||||
text: Schema.String,
|
||||
})
|
||||
const OpenResponsesInputImage = Schema.Struct({
|
||||
export const OpenResponsesInputImage = Schema.Struct({
|
||||
type: Schema.tag("input_image"),
|
||||
image_url: Schema.String,
|
||||
detail: Schema.optional(Schema.String),
|
||||
})
|
||||
const OpenResponsesInputFile = Schema.Struct({
|
||||
type: Schema.tag("input_file"),
|
||||
@@ -54,7 +55,7 @@ const MediaInput = Schema.Union([OpenResponsesInputImage, OpenResponsesInputFile
|
||||
export type MediaInput = Schema.Schema.Type<typeof MediaInput>
|
||||
const OpenResponsesInputContent = Schema.Union([OpenResponsesInputText, MediaInput])
|
||||
|
||||
const OpenResponsesOutputText = Schema.Struct({
|
||||
export const OpenResponsesOutputText = Schema.Struct({
|
||||
type: Schema.tag("output_text"),
|
||||
text: Schema.String,
|
||||
})
|
||||
@@ -62,6 +63,13 @@ 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
|
||||
@@ -72,7 +80,7 @@ const OpenResponsesReasoningSummaryText = Schema.Struct({
|
||||
text: Schema.String,
|
||||
})
|
||||
|
||||
const OpenResponsesReasoningItem = Schema.Struct({
|
||||
export const OpenResponsesReasoningItem = Schema.Struct({
|
||||
type: Schema.tag("reasoning"),
|
||||
id: Schema.optionalKey(Schema.String),
|
||||
summary: Schema.Array(OpenResponsesReasoningSummaryText),
|
||||
@@ -149,16 +157,30 @@ 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) }),
|
||||
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({
|
||||
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({
|
||||
@@ -267,7 +289,7 @@ const OpenResponsesBody = Schema.Struct({
|
||||
})
|
||||
export type OpenResponsesBody = Schema.Schema.Type<typeof OpenResponsesBody>
|
||||
|
||||
const OpenResponsesUsage = Schema.Struct({
|
||||
export const OpenResponsesUsage = Schema.Struct({
|
||||
input_tokens: Schema.optional(Schema.Number),
|
||||
input_tokens_details: optionalNull(
|
||||
Schema.Struct({
|
||||
@@ -387,6 +409,8 @@ export interface Extension {
|
||||
const BASE: Extension = { 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
|
||||
@@ -501,7 +525,13 @@ const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
|
||||
...(url ? { file_url: url } : { file_data: media.dataUrl }),
|
||||
}
|
||||
}
|
||||
return { type: "input_image" as const, image_url: url ?? media.dataUrl }
|
||||
return {
|
||||
type: "input_image" as const,
|
||||
image_url: url ?? media.dataUrl,
|
||||
detail: yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenResponsesInputImage.fields.detail))(
|
||||
part.providerMetadata?.[request.model.route.providerMetadataKey ?? "openresponses"]?.detail,
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
const lowerUserContent = Effect.fnUntraced(function* (
|
||||
@@ -568,6 +598,9 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
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",
|
||||
@@ -578,7 +611,8 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
|
||||
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 })
|
||||
if (content.length > 0)
|
||||
input.push({ role: "user", content, type: metadata?.type, id: metadata?.itemId, status: metadata?.status })
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -591,9 +625,10 @@ 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 metadata = part.providerMetadata?.[providerMetadataKey]
|
||||
const id = itemID(part.providerMetadata, providerMetadataKey)
|
||||
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase) : undefined
|
||||
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 group = groups.at(-1)
|
||||
if (group && group.id === id && group.phase === phase) group.parts.push(part)
|
||||
else groups.push({ id, phase, parts: [part] })
|
||||
@@ -604,6 +639,7 @@ 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 }),
|
||||
})),
|
||||
@@ -611,6 +647,15 @@ 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
|
||||
@@ -689,13 +734,30 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
return input
|
||||
})
|
||||
|
||||
const lowerOptions = (request: LLMRequest) => {
|
||||
const options = OpenResponsesOptions.resolve(request)
|
||||
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 options = OpenResponsesOptions.resolve(request)
|
||||
const generation = request.generation
|
||||
const cacheKey = ProviderShared.promptCacheKey(request)
|
||||
const parallelToolCalls = resolveParallelToolCalls(request)
|
||||
return {
|
||||
...(instructions ? { instructions } : {}),
|
||||
stream: true as const,
|
||||
max_output_tokens: generation?.maxTokens,
|
||||
temperature: generation?.temperature,
|
||||
top_p: generation?.topP,
|
||||
presence_penalty: generation?.presencePenalty,
|
||||
frequency_penalty: generation?.frequencyPenalty,
|
||||
...(options.store !== undefined ? { store: options.store } : {}),
|
||||
...(options.metadata ? { metadata: options.metadata } : {}),
|
||||
...(options.safetyIdentifier ? { safety_identifier: options.safetyIdentifier } : {}),
|
||||
@@ -723,7 +785,7 @@ export const resolveParallelToolCalls = (request: LLMRequest) => {
|
||||
return disabled === undefined ? undefined : !disabled
|
||||
}
|
||||
|
||||
const allowedToolChoice = (request: LLMRequest) => {
|
||||
export const allowedToolChoice = (request: LLMRequest) => {
|
||||
const allowed = OpenResponsesOptions.resolve(request).allowedTools
|
||||
if (!allowed) return undefined
|
||||
return {
|
||||
@@ -737,11 +799,10 @@ export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWith
|
||||
request: LLMRequest,
|
||||
extension: Extension,
|
||||
) {
|
||||
const generation = request.generation
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
return {
|
||||
model: request.model.id,
|
||||
input: yield* lowerMessages(request, extension),
|
||||
...(yield* lowerConversation(request, extension)),
|
||||
...lowerGeneration(request),
|
||||
tools:
|
||||
request.tools.length === 0
|
||||
? undefined
|
||||
@@ -755,13 +816,6 @@ export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWith
|
||||
tool_choice:
|
||||
allowedToolChoice(request) ??
|
||||
(request.toolChoice ? yield* lowerToolChoice(extension.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),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -778,7 +832,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.
|
||||
const mapUsage = (usage: OpenResponsesUsage | null | undefined, providerMetadataKey: string) => {
|
||||
export 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
|
||||
@@ -1084,6 +1138,25 @@ 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
|
||||
const itemPhase = messagePhase(item.phase)
|
||||
@@ -1243,26 +1316,34 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
|
||||
const events: LLMEvent[] = []
|
||||
if (event.type === "response.completed") {
|
||||
for (const item of event.response?.output ?? []) {
|
||||
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
|
||||
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 [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, hasFunctionCall),
|
||||
normalized: mapFinishReason(event, current.hasFunctionCall),
|
||||
raw: event.response?.incomplete_details?.reason,
|
||||
},
|
||||
usage: mapUsage(event.response?.usage, current.providerMetadataKey),
|
||||
@@ -1274,7 +1355,7 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
|
||||
})
|
||||
: undefined,
|
||||
})
|
||||
return [{ ...current, lifecycle, hasFunctionCall, tools: pending.tools }, events] satisfies StepResult
|
||||
return [{ ...current, lifecycle }, events] satisfies StepResult
|
||||
})
|
||||
|
||||
// Build the prettiest summary available from whatever the provider supplied.
|
||||
@@ -1409,6 +1490,8 @@ export const step = (state: ParserState, input: Event) => {
|
||||
* 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: request.model.route.providerMetadataKey ?? "openresponses",
|
||||
|
||||
@@ -5,13 +5,14 @@ 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 { LLMRequest, type JsonSchema, type ToolDefinition } from "../schema/index.js"
|
||||
import type { LLMRequest, JsonSchema, 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"
|
||||
@@ -20,6 +21,14 @@ 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"])),
|
||||
@@ -78,6 +87,14 @@ 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({
|
||||
@@ -125,15 +142,14 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tool
|
||||
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesBody))
|
||||
|
||||
const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) {
|
||||
const body = yield* OpenResponses.fromRequestWithExtension(
|
||||
LLMRequest.update(request, { tools: [], toolChoice: undefined }),
|
||||
extension,
|
||||
)
|
||||
const management = yield* ProviderShared.validateWith(
|
||||
Schema.decodeUnknownEffect(Schema.UndefinedOr(ContextManagement)),
|
||||
)(request.providerOptions?.contextManagement)
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
const parallelToolCalls = OpenResponses.resolveParallelToolCalls(request)
|
||||
return yield* decodeBody({
|
||||
...body,
|
||||
...(parallelToolCalls === undefined ? {} : { parallel_tool_calls: parallelToolCalls }),
|
||||
...(yield* OpenResponses.lowerConversation(request, extension)),
|
||||
...OpenResponses.lowerGeneration(request),
|
||||
context_management: management?.map((edit) => ({ type: edit.type, compact_threshold: edit.compactThreshold })),
|
||||
tools:
|
||||
request.tools.length === 0
|
||||
? undefined
|
||||
@@ -141,7 +157,8 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
|
||||
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
|
||||
),
|
||||
tool_choice:
|
||||
body.tool_choice ?? (request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined),
|
||||
OpenResponses.allowedToolChoice(request) ??
|
||||
(request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -223,6 +240,7 @@ export const transport = channelTransport({
|
||||
})
|
||||
|
||||
export const route = Route.make({
|
||||
compact: ResponsesCompaction.make(extension),
|
||||
id: ADAPTER,
|
||||
provider: "openai",
|
||||
providerMetadataKey: "openai",
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
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, ProviderShared } from "../shared.js"
|
||||
|
||||
const Body = Schema.Struct({
|
||||
model: Schema.String,
|
||||
input: Schema.Array(Schema.Unknown),
|
||||
instructions: Schema.optional(Schema.String),
|
||||
previous_response_id: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const Text = Schema.Union([OpenResponses.OpenResponsesInputText, OpenResponses.OpenResponsesOutputText])
|
||||
const File = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal("input_file"), filename: Schema.String, file_url: Schema.String }),
|
||||
Schema.Struct({ type: Schema.Literal("input_file"), filename: Schema.String, file_data: Schema.String }),
|
||||
])
|
||||
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, 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, route.providerMetadataKey ?? String(request.model.provider)),
|
||||
})
|
||||
})
|
||||
|
||||
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 = model.route.providerMetadataKey ?? String(model.provider)
|
||||
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 = "file_url" in part ? part.file_url : part.file_data
|
||||
return {
|
||||
type: "media",
|
||||
data,
|
||||
filename: part.filename,
|
||||
mediaType: /^data:([^;,]+)/.exec(data)?.[1] ?? "application/octet-stream",
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export * as ResponsesCompaction from "./responses-compaction.js"
|
||||
@@ -4,6 +4,7 @@ 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"
|
||||
@@ -44,6 +45,10 @@ const extension = {
|
||||
|
||||
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))
|
||||
})
|
||||
|
||||
@@ -84,4 +89,6 @@ export const protocol = Protocol.make({
|
||||
},
|
||||
})
|
||||
|
||||
export const compact = ResponsesCompaction.make(extension)
|
||||
|
||||
export * as XAIResponses from "./xai-responses.js"
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import type { Route, 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")
|
||||
|
||||
@@ -25,38 +27,40 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
readonly region?: string
|
||||
readonly topP?: number
|
||||
}
|
||||
export const routes = [BedrockConverse.route]
|
||||
export const routes = [BedrockConverse.route, BedrockMessages.route]
|
||||
|
||||
const bedrockBaseURL = (region: string) => `https://bedrock-runtime.${region}.amazonaws.com`
|
||||
|
||||
const configuredRoute = (input: Config) => {
|
||||
const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Config) => {
|
||||
const { apiKey, credentials, region, baseURL, ...rest } = input
|
||||
const resolvedRegion = region ?? credentials?.region ?? "us-east-1"
|
||||
return BedrockConverse.route.with({
|
||||
return route.with({
|
||||
...rest,
|
||||
provider: id,
|
||||
providerMetadataKey: "bedrock",
|
||||
providerMetadataKey: route.providerMetadataKey,
|
||||
endpoint: { baseURL: baseURL ?? bedrockBaseURL(resolvedRegion) },
|
||||
auth: apiKey === undefined ? BedrockConverse.sigV4Auth(credentials) : Auth.bearer(apiKey),
|
||||
})
|
||||
}
|
||||
|
||||
export const configure = (input: Config = {}) => {
|
||||
const route = configuredRoute(input)
|
||||
const route = configuredRoute(BedrockConverse.route, input)
|
||||
const messages = configuredRoute(BedrockMessages.route, 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()
|
||||
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
|
||||
const config = (settings: Settings): Config => {
|
||||
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 configure({
|
||||
return {
|
||||
apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
credentials: settings.credentials,
|
||||
@@ -64,5 +68,13 @@ export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, se
|
||||
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)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { messagesModel as model } from "../amazon-bedrock.js"
|
||||
@@ -57,7 +57,9 @@ const route = Route.make({
|
||||
}),
|
||||
endpoint: Endpoint.path(({ request }) => `/${request.model.id}:streamRawPredict`),
|
||||
auth: Auth.none,
|
||||
framing: AnthropicMessages.framing,
|
||||
transport: AnthropicMessages.transport<
|
||||
Omit<AnthropicMessages.AnthropicMessagesBody, "model"> & { readonly anthropic_version: typeof VERSION }
|
||||
>(),
|
||||
headers: () => ({ "anthropic-version": HEADER_VERSION }),
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
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
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { ProviderPackage } from "../provider-package.js"
|
||||
|
||||
export const id = ProviderID.make("xai")
|
||||
|
||||
export type XAIProviderOptionsInput = OpenAIOptionsInput
|
||||
export type XAIProviderOptionsInput = OpenAIOptionsInput & { readonly contextManagement?: never }
|
||||
|
||||
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
@@ -32,6 +32,7 @@ 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",
|
||||
|
||||
@@ -13,6 +13,7 @@ import * as ProviderShared from "../protocols/shared.js"
|
||||
import type { ProtocolID, ProviderOptions } from "../schema/index.js"
|
||||
import {
|
||||
AIError,
|
||||
CompactionResponse,
|
||||
AIErrorReason,
|
||||
GenerationOptions,
|
||||
HttpOptions,
|
||||
@@ -35,6 +36,7 @@ export interface RouteBody<Body> {
|
||||
}
|
||||
|
||||
export interface Route<Body, Prepared = unknown> {
|
||||
readonly compact?: CompactOperation
|
||||
readonly id: string
|
||||
readonly provider?: ProviderID
|
||||
/** ProviderMetadata namespace emitted and consumed by this route. */
|
||||
@@ -42,6 +44,8 @@ export interface Route<Body, Prepared = unknown> {
|
||||
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>
|
||||
@@ -150,6 +154,10 @@ export const httpOptions = (input: HttpOptionsInput | undefined) => {
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly compact: (
|
||||
request: LLMRequest,
|
||||
options?: Pick<StreamOptions, "http">,
|
||||
) => Effect.Effect<CompactionResponse, AIError>
|
||||
readonly stream: StreamMethod
|
||||
readonly generate: GenerateMethod
|
||||
}
|
||||
@@ -167,6 +175,12 @@ 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 class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
|
||||
|
||||
const resolveRequestOptions = (request: LLMRequest) => {
|
||||
@@ -187,6 +201,7 @@ 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. */
|
||||
@@ -208,6 +223,7 @@ 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. */
|
||||
@@ -283,12 +299,14 @@ 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,
|
||||
@@ -318,7 +336,6 @@ 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,
|
||||
}),
|
||||
@@ -435,6 +452,7 @@ 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,
|
||||
@@ -447,11 +465,19 @@ export function make<Body, Prepared, Frame, Event, State>(
|
||||
})
|
||||
}
|
||||
|
||||
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest, options?: StreamOptions) {
|
||||
const prepareRequest = (request: LLMRequest) => {
|
||||
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
|
||||
@@ -510,6 +536,15 @@ export function generate(request: LLMRequest, options?: StreamOptions): Effect.E
|
||||
})
|
||||
}
|
||||
|
||||
export const compact = (
|
||||
request: LLMRequest,
|
||||
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* () {
|
||||
@@ -520,16 +555,28 @@ export const streamRequest = (request: LLMRequest, options?: StreamOptions) =>
|
||||
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const stream = streamRequestWith({
|
||||
http: yield* RequestExecutor.Service,
|
||||
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)
|
||||
}),
|
||||
})
|
||||
return Service.of({ stream, generate: generateWith(stream) })
|
||||
}),
|
||||
)
|
||||
|
||||
export const Route = { make } as const
|
||||
|
||||
export const LLMClient = {
|
||||
compact,
|
||||
Service,
|
||||
layer,
|
||||
stream,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { LLM } from "@opencode-ai/schema/llm"
|
||||
import { ContentBlockID, ToolCallID } from "./ids.js"
|
||||
import {
|
||||
Message,
|
||||
CompactionPart,
|
||||
ProviderMetadata,
|
||||
ToolCallPart,
|
||||
ToolOutput,
|
||||
@@ -62,6 +63,8 @@ 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),
|
||||
@@ -72,7 +75,7 @@ export class Usage extends Schema.Class<Usage>("AI.Usage")({
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}) {
|
||||
/**
|
||||
* Visible output tokens — `outputTokens` minus `reasoningTokens`, clamped
|
||||
* Non-reasoning output tokens (including compaction summaries) — `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.
|
||||
@@ -88,6 +91,12 @@ 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,
|
||||
@@ -241,6 +250,7 @@ export const ProviderErrorEvent = Schema.Struct({
|
||||
export type ProviderErrorEvent = Schema.Schema.Type<typeof ProviderErrorEvent>
|
||||
|
||||
const llmEventTagged = Schema.Union([
|
||||
CompactionPart,
|
||||
StepStart,
|
||||
TextStart,
|
||||
TextDelta,
|
||||
@@ -274,6 +284,7 @@ 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) }),
|
||||
@@ -311,6 +322,7 @@ 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"],
|
||||
@@ -333,10 +345,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 = <Delta extends { id: string; text: string }, End extends { id: string; text?: string }>(
|
||||
const joinFragments = (
|
||||
events: ReadonlyArray<LLMEvent>,
|
||||
isDelta: (event: LLMEvent) => event is Extract<LLMEvent, Delta>,
|
||||
isEnd: (event: LLMEvent) => event is Extract<LLMEvent, End>,
|
||||
isDelta: (event: LLMEvent) => event is LLMEvent & { id: string; text: string },
|
||||
isEnd: (event: LLMEvent) => event is LLMEvent & { id: string; text?: string },
|
||||
) => {
|
||||
const order: string[] = []
|
||||
const parts = new Map<string, string>()
|
||||
@@ -563,6 +575,8 @@ 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":
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
LanguageModelSchema,
|
||||
ProviderOptions,
|
||||
} from "./options.js"
|
||||
import { isRecord } from "../utils/record.js"
|
||||
import { ProviderID } from "./ids.js"
|
||||
|
||||
export const MessageRole = Schema.Literals(["system", "user", "assistant", "tool"])
|
||||
export type MessageRole = Schema.Schema.Type<typeof MessageRole>
|
||||
@@ -53,14 +53,10 @@ 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>
|
||||
|
||||
const isToolResultValue = (value: unknown): value is ToolResultValue =>
|
||||
isRecord(value) &&
|
||||
(value.type === "text" || value.type === "json" || value.type === "error" || value.type === "content") &&
|
||||
"value" in value
|
||||
|
||||
const toolResultValueSchema = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("json"),
|
||||
@@ -80,6 +76,7 @@ const toolResultValueSchema = Schema.Union([
|
||||
}),
|
||||
]).annotate({ identifier: "LLM.ToolResult" })
|
||||
export type ToolResultValue = Schema.Schema.Type<typeof toolResultValueSchema>
|
||||
const isToolResultValue = Schema.is(toolResultValueSchema)
|
||||
|
||||
export const ToolResultValue = Object.assign(toolResultValueSchema, {
|
||||
is: isToolResultValue,
|
||||
@@ -190,9 +187,40 @@ export const ReasoningPart = Schema.Struct({
|
||||
}).annotate({ identifier: "LLM.Content.Reasoning" })
|
||||
export type ReasoningPart = Schema.Schema.Type<typeof ReasoningPart>
|
||||
|
||||
export const ContentPart = Schema.Union([TextPart, MediaPart, ToolCallPart, ToolResultPart, ReasoningPart]).pipe(
|
||||
Schema.toTaggedUnion("type"),
|
||||
)
|
||||
/** 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 type ContentPart = Schema.Schema.Type<typeof ContentPart>
|
||||
|
||||
export class Message extends Schema.Class<Message>("LLM.Message")({
|
||||
@@ -200,6 +228,7 @@ 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)),
|
||||
}) {}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { LLMClient } from "./route/client.js"
|
||||
import {
|
||||
LLMEvent,
|
||||
LLMResponse,
|
||||
CompactionResponse,
|
||||
type FinishReasonDetails,
|
||||
type AIError,
|
||||
type LLMRequest,
|
||||
@@ -133,6 +134,16 @@ const make = (options: LayerOptions) =>
|
||||
}
|
||||
})
|
||||
const test = Test.of({
|
||||
compact: (request) =>
|
||||
stream(request).pipe(
|
||||
Stream.runFold(LLMResponse.empty, LLMResponse.reduce),
|
||||
Effect.flatMap((state) => {
|
||||
const response = LLMResponse.complete(state)
|
||||
if (!response?.message.content.some((part) => part.type === "compaction"))
|
||||
return Effect.die("TestLLM compaction response must contain a checkpoint and terminal finish event")
|
||||
return Effect.succeed(new CompactionResponse({ messages: [response.message], usage: response.usage }))
|
||||
}),
|
||||
),
|
||||
stream,
|
||||
generate: (request) =>
|
||||
stream(request).pipe(
|
||||
|
||||
@@ -56,6 +56,7 @@ function normalizeToolMessage(message: Message, pending: Map<string, ToolCallPar
|
||||
role: message.role,
|
||||
content,
|
||||
metadata: message.metadata,
|
||||
providerMetadata: message.providerMetadata,
|
||||
native: message.native,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -126,6 +126,38 @@ 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(
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
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"])
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { CompactionPart, LLMEvent, LLMResponse, Message, ProviderID } from "../src/schema/index.js"
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Effect } from "effect"
|
||||
import { CompactionPart, LLM, LLMClient, LLMEvent, Message, ProviderID } from "../../src/index.js"
|
||||
import { OpenAI, Anthropic, AmazonBedrock } 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" }))
|
||||
|
||||
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" }] },
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
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)
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
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, fixedResponse } 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")] }),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
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)
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,91 @@
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,136 @@
|
||||
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")
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,377 @@
|
||||
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" },
|
||||
{ type: "input_file", filename: "other.pdf", file_url: "https://example.com/report.pdf" },
|
||||
],
|
||||
},
|
||||
checkpoint,
|
||||
]
|
||||
|
||||
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("retained messages, reasoning, and media are ordinary typed conversation parts", () =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({
|
||||
model: OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"),
|
||||
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 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" }] },
|
||||
]) {
|
||||
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* () {
|
||||
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)
|
||||
}),
|
||||
)
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
QuotaExceededError,
|
||||
RateLimitError,
|
||||
RouteID,
|
||||
ToolResultValue,
|
||||
TransportError,
|
||||
UnknownProviderError,
|
||||
Usage,
|
||||
@@ -83,6 +84,37 @@ describe("llm schema", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("ToolResultValue", () => {
|
||||
test("uses the canonical schema guard", () => {
|
||||
const cases: ReadonlyArray<{ readonly value: unknown; readonly expected: boolean }> = [
|
||||
{ value: { type: "json", value: { ok: true } }, expected: true },
|
||||
{ value: { type: "text", value: "done" }, expected: true },
|
||||
{ value: { type: "error", value: "failed" }, expected: true },
|
||||
{ value: { type: "content", value: [{ type: "text", text: "done" }] }, expected: true },
|
||||
{ value: { type: "content", value: [{ type: "text" }] }, expected: false },
|
||||
{ value: { type: "content", value: "done" }, expected: false },
|
||||
{ value: { type: "json" }, expected: false },
|
||||
{ value: { type: "unknown", value: "done" }, expected: false },
|
||||
]
|
||||
|
||||
for (const item of cases) {
|
||||
expect(Schema.is(ToolResultValue)(item.value)).toBe(item.expected)
|
||||
expect(ToolResultValue.is(item.value)).toBe(item.expected)
|
||||
}
|
||||
})
|
||||
|
||||
test("accepts canonical results with extra fields", () => {
|
||||
expect(ToolResultValue.is({ type: "json", value: { ok: true }, metadata: { source: "tool" } })).toBe(true)
|
||||
expect(
|
||||
ToolResultValue.is({
|
||||
type: "content",
|
||||
value: [{ type: "file", uri: "https://example.test/result.txt", mime: "text/plain", checksum: "abc" }],
|
||||
metadata: { source: "tool" },
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("AI.Usage", () => {
|
||||
test("subtractTokens clamps non-sensical breakdowns to zero", () => {
|
||||
// Defense against a provider reporting cached_tokens > prompt_tokens or
|
||||
|
||||
@@ -442,6 +442,33 @@ describe("LLMClient tools", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("projects malformed tagged dynamic output as opaque JSON", () =>
|
||||
Effect.gen(function* () {
|
||||
const malformed = { type: "content", value: [{ type: "text" }] }
|
||||
const dynamic = Tool.make({
|
||||
description: "Return caller-defined JSON.",
|
||||
jsonSchema: { type: "object", properties: {} },
|
||||
execute: () => Effect.succeed(malformed),
|
||||
})
|
||||
|
||||
const dispatched = yield* ToolRuntime.dispatch(
|
||||
{ dynamic },
|
||||
LLMEvent.toolCall({ id: "call_1", name: "dynamic", input: {} }),
|
||||
)
|
||||
|
||||
expect(dispatched.result).toEqual({ type: "json", value: malformed })
|
||||
expect(dispatched.output).toEqual({ structured: malformed, content: [] })
|
||||
expect(dispatched.events).toEqual([
|
||||
LLMEvent.toolResult({
|
||||
id: "call_1",
|
||||
name: "dynamic",
|
||||
result: { type: "json", value: malformed },
|
||||
output: { structured: malformed, content: [] },
|
||||
}),
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("executes tool calls for one step without looping by default", () =>
|
||||
Effect.gen(function* () {
|
||||
const layer = scriptedResponses([
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture } from "../smoke/session-timeline.fixture"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true })
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
provider: fixture.provider,
|
||||
sessions: fixture.sessions,
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ directory, server, sessions }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(sessions.map((session) => ({ type: "session", server, sessionId: session.id }))),
|
||||
)
|
||||
},
|
||||
{ directory: fixture.directory, server: fixture.serverKey, sessions: fixture.sessions },
|
||||
)
|
||||
await page.goto("/")
|
||||
await expect(page.locator('[data-component="home-session-row"]')).toHaveCount(fixture.sessions.length)
|
||||
})
|
||||
|
||||
test("mobile project selection and drawer navigation preserve session identity", async ({ page }) => {
|
||||
const projects = page.getByRole("button", { name: "Projects", exact: true })
|
||||
await projects.click()
|
||||
const picker = page.getByRole("dialog", { name: "Projects", exact: true })
|
||||
await expect(picker.getByRole("button", { name: "All projects", exact: true })).toBeVisible()
|
||||
await picker.getByRole("button", { name: new RegExp(` ${fixture.project.name}$`) }).click()
|
||||
await expect(picker).toBeHidden()
|
||||
await expect(projects).toContainText(fixture.project.name)
|
||||
|
||||
const trigger = page.locator('[data-slot="mobile-tabs-trigger"]')
|
||||
const drawer = page.locator('[data-slot="mobile-tabs-drawer"]')
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(drawer.locator('[data-slot="tab-project"]')).toHaveText([fixture.project.name, fixture.project.name])
|
||||
const settings = drawer.getByRole("button", { name: "Settings", exact: true })
|
||||
const help = drawer.getByRole("button", { name: "Help", exact: true })
|
||||
await expect(settings).toBeVisible()
|
||||
await expect(help).toBeVisible()
|
||||
expect((await settings.boundingBox())?.width).toBeGreaterThan((await help.boundingBox())?.width ?? 0)
|
||||
|
||||
await drawer.locator('[data-slot="tab-link"]').filter({ hasText: fixture.expected.sourceTitle }).click()
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${fixture.sourceID}$`))
|
||||
await expect(drawer).toBeHidden()
|
||||
await expect(trigger).toContainText(fixture.expected.sourceTitle)
|
||||
await trigger.click()
|
||||
await drawer.getByRole("button", { name: "Home", exact: true }).click()
|
||||
await expect(page).toHaveURL("/")
|
||||
await expect(drawer).toBeHidden()
|
||||
await expect(page.locator('[data-component="home-session-row"]')).toHaveCount(fixture.sessions.length)
|
||||
})
|
||||
|
||||
test("mobile settings section menu stays above a full-width panel", async ({ page }) => {
|
||||
await page.locator('[data-slot="mobile-tabs-trigger"]').click()
|
||||
await page.locator('[data-slot="mobile-tabs-drawer"]').getByRole("button", { name: "Settings", exact: true }).click()
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
const menu = settings.getByRole("button", { name: "Preferences", exact: true })
|
||||
const panel = settings.getByRole("tabpanel")
|
||||
await expect(settings.getByRole("heading", { name: "General", exact: true })).toBeVisible()
|
||||
await expect(menu).toBeVisible()
|
||||
await menu.click()
|
||||
await expect(page.getByRole("menuitemradio", { name: "Preferences", exact: true })).toBeChecked()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(page.getByRole("menuitemradio", { name: "Preferences", exact: true })).toBeHidden()
|
||||
await expect(settings).toBeVisible()
|
||||
await menu.click()
|
||||
await page.getByRole("menuitemradio", { name: "Models", exact: true }).click()
|
||||
await expect(settings.getByRole("heading", { name: "Models", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("button", { name: "Models", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("switch", { name: "Claude Opus 4.6", exact: true })).toBeAttached()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const navigation = await settings.getByRole("button", { name: "Models", exact: true }).boundingBox()
|
||||
const content = await panel.boundingBox()
|
||||
return !!navigation && !!content && navigation.y + navigation.height <= content.y
|
||||
})
|
||||
.toBe(true)
|
||||
await expect.poll(async () => (await panel.boundingBox())?.width ?? 0).toBeGreaterThan(350)
|
||||
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
|
||||
await expect(settings).toBeHidden()
|
||||
await expect(page.locator('[data-component="home-session-row"]')).toHaveCount(fixture.sessions.length)
|
||||
})
|
||||
@@ -69,7 +69,7 @@ test("keyboard navigation follows the visible tab order", async ({ page }) => {
|
||||
await expect(page).toHaveURL(new RegExp(`${hrefC.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||
})
|
||||
|
||||
test("cramped tabs only show the close button for the active tab", async ({ page }) => {
|
||||
test("mobile drawer exposes close controls and navigates between tabs", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 360, height: 720 })
|
||||
await mockServer(page)
|
||||
await page.addInitScript(
|
||||
@@ -89,26 +89,31 @@ test("cramped tabs only show the close button for the active tab", async ({ page
|
||||
const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}`
|
||||
const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}`
|
||||
await page.goto(hrefA)
|
||||
await page.getByRole("button", { name: "Tabs", exact: true }).click()
|
||||
|
||||
const tabA = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefA}"])`)
|
||||
const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`)
|
||||
await expect(tabA).toHaveAttribute("data-active", "true")
|
||||
await expect(tabB).toBeVisible()
|
||||
await expect(tabA.locator('[data-slot="tab-close"]')).toBeVisible()
|
||||
await expect(tabB.locator('[data-slot="tab-close"]')).toBeHidden()
|
||||
await expect(tabB.locator('[data-slot="tab-close"]')).toBeVisible()
|
||||
|
||||
await tabB.locator(`a[href="${hrefB}"]`).click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||
await expect(tabA.locator('[data-slot="tab-close"]')).toBeHidden()
|
||||
await expect(page.getByRole("dialog", { name: "Tabs", exact: true })).toBeHidden()
|
||||
await page.getByRole("button", { name: "Tabs", exact: true }).click()
|
||||
await expect(tabA.locator('[data-slot="tab-close"]')).toBeVisible()
|
||||
await expect(tabB.locator('[data-slot="tab-close"]')).toBeVisible()
|
||||
|
||||
for (const direction of ["ltr", "rtl"]) {
|
||||
await page.evaluate((direction) => document.documentElement.setAttribute("dir", direction), direction)
|
||||
await page.setViewportSize({ width: 450, height: 720 })
|
||||
await expect(tabA.locator("[data-titlebar-tab]")).toHaveAttribute("data-title-overflow", "true")
|
||||
await expect(tabA).toBeVisible()
|
||||
await page.setViewportSize({ width: 1280, height: 720 })
|
||||
await expect(tabA.locator("[data-titlebar-tab]")).toHaveAttribute("data-title-overflow", "false")
|
||||
await page.setViewportSize({ width: 450, height: 720 })
|
||||
await page.getByRole("button", { name: "Tabs", exact: true }).click()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -202,28 +207,28 @@ test("appearance experimental setting switches tab orientation", async ({ page }
|
||||
|
||||
await page.setViewportSize({ width: 920, height: 720 })
|
||||
await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toHaveCSS("width", "260px")
|
||||
await expect(settings.getByRole("tablist")).toHaveCSS("width", "160px")
|
||||
await expect(settings.getByRole("tablist")).toBeHidden()
|
||||
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeVisible()
|
||||
|
||||
await page.setViewportSize({ width: 800, height: 720 })
|
||||
await expect(settings.getByRole("tablist")).toHaveCSS("width", "160px")
|
||||
await expect(version).toBeInViewport()
|
||||
await expect(settings.getByRole("tablist")).toBeHidden()
|
||||
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeVisible()
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 720 })
|
||||
await expect(version).toBeInViewport()
|
||||
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeVisible()
|
||||
await settings.evaluate((element) => element.setAttribute("dir", "rtl"))
|
||||
await expect(version).toBeInViewport()
|
||||
await expect(version).toHaveCSS("direction", "ltr")
|
||||
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport()
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 360 })
|
||||
await version.scrollIntoViewIfNeeded()
|
||||
await expect(version).toBeInViewport()
|
||||
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport()
|
||||
|
||||
// Reload the UI-selected preference without seeding settings storage.
|
||||
await page.reload()
|
||||
const href = `/server/${base64Encode(server)}/session/${sessionA.id}`
|
||||
await page.getByRole("button", { name: "Tabs", exact: true }).click()
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-slot="titlebar-tabs"]')
|
||||
.locator('[data-slot="mobile-tabs-drawer"]')
|
||||
.locator(`[data-titlebar-tab-link][href="${href}"]`)
|
||||
.getByText(sessionA.title, { exact: true }),
|
||||
).toBeVisible()
|
||||
@@ -242,7 +247,7 @@ test("appearance experimental setting switches tab orientation", async ({ page }
|
||||
await expect(layout).toContainText("Vertical")
|
||||
})
|
||||
|
||||
test("vertical tab preference falls back to horizontal on mobile", async ({ page }) => {
|
||||
test("vertical tab preference uses the drawer on mobile", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 720 })
|
||||
await mockServer(page)
|
||||
await page.addInitScript(
|
||||
@@ -259,7 +264,8 @@ test("vertical tab preference falls back to horizontal on mobile", async ({ page
|
||||
const href = `/server/${base64Encode(server)}/session/${sessionA.id}`
|
||||
await page.goto(href)
|
||||
|
||||
const tabs = page.locator('[data-slot="titlebar-tabs"]')
|
||||
await page.getByRole("button", { name: "Tabs", exact: true }).click()
|
||||
const tabs = page.locator('[data-slot="mobile-tabs-drawer"]')
|
||||
await expect(tabs.locator(`[data-titlebar-tab-link][href="${href}"]`)).toContainText(sessionA.title)
|
||||
await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toHaveCount(0)
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
"vite-plugin-solid": "2.11.14"
|
||||
},
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
"@dnd-kit/dom": "0.5.0",
|
||||
"@dnd-kit/helpers": "0.5.0",
|
||||
|
||||
@@ -2,9 +2,14 @@ import type { HomeProjectsController } from "./controller"
|
||||
import { HomeProjectsView } from "./view"
|
||||
import type { HomeScrollController } from "../scroll"
|
||||
|
||||
export function HomeProjects(props: { projects: HomeProjectsController; scroll: HomeScrollController }) {
|
||||
export function HomeProjects(props: {
|
||||
projects: HomeProjectsController
|
||||
scroll: HomeScrollController
|
||||
dropdown?: boolean
|
||||
}) {
|
||||
return (
|
||||
<HomeProjectsView
|
||||
dropdown={props.dropdown}
|
||||
language={props.projects.copy.language}
|
||||
servers={props.projects.server.list()}
|
||||
projects={props.projects.project.list()}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createMemo, For, type JSX, onCleanup, Show, splitProps } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Popover } from "@kobalte/core/popover"
|
||||
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
|
||||
import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
|
||||
import { AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
|
||||
@@ -28,6 +29,7 @@ const projectContextMenuID = (server: ServerConnection.Any, directory: string) =
|
||||
`project:${ServerConnection.key(server)}:${directory}`
|
||||
|
||||
export type HomeProjectsViewProps = {
|
||||
dropdown?: boolean
|
||||
language: ReturnType<typeof useLanguage>
|
||||
servers: ServerConnection.Any[]
|
||||
projects: LocalProject[]
|
||||
@@ -64,6 +66,81 @@ export type HomeProjectsViewProps = {
|
||||
}
|
||||
|
||||
export function HomeProjectsView(props: HomeProjectsViewProps) {
|
||||
const [state, setState] = createStore({ open: false })
|
||||
const selected = createMemo(() => props.projects.find((project) => project.worktree === props.selection.directory))
|
||||
const server = createMemo(() =>
|
||||
props.servers.find((server) => ServerConnection.key(server) === props.selection.server),
|
||||
)
|
||||
return (
|
||||
<Show when={props.dropdown} fallback={<HomeProjectsPanel {...props} />}>
|
||||
<Popover
|
||||
open={state.open}
|
||||
onOpenChange={(open) => setState("open", open)}
|
||||
placement="bottom-start"
|
||||
sameWidth
|
||||
gutter={6}
|
||||
>
|
||||
<Popover.Trigger
|
||||
data-component="home-projects-dropdown"
|
||||
aria-label={props.language.t("home.projects")}
|
||||
class="flex h-10 w-full min-w-0 items-center gap-2 rounded-[6px] bg-v2-background-bg-base px-1.5 text-start text-v2-text-text-base outline-none hover:bg-v2-background-bg-layer-01"
|
||||
>
|
||||
<Show when={selected()} fallback={<Icon name="folder" size="small" />}>
|
||||
{(project) => <HomeProjectAvatar project={project()} />}
|
||||
</Show>
|
||||
<span class="flex min-w-0 flex-1 flex-col leading-[var(--line-height-compact)]">
|
||||
<bdi class="truncate">
|
||||
<Show when={selected()} fallback={props.language.t("home.projects.all")}>
|
||||
{(project) => displayName(project())}
|
||||
</Show>
|
||||
</bdi>
|
||||
<Show when={props.servers.length > 1 && server()}>
|
||||
{(server) => (
|
||||
<bdi class="truncate text-v2-text-text-muted opacity-70">
|
||||
{server().displayName ?? new URL(server().http.url).host}
|
||||
</bdi>
|
||||
)}
|
||||
</Show>
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
aria-label={props.language.t("home.projects")}
|
||||
dir={props.language.direction()}
|
||||
class="z-50 max-h-[min(70dvh,var(--kb-popper-content-available-height))] overflow-hidden rounded-[10px] bg-v2-background-bg-base p-1.5 shadow-[var(--v2-elevation-floating)] outline-none data-[expanded]:animate-in data-[expanded]:fade-in data-[expanded]:slide-in-from-top-2 duration-150 ease-out motion-reduce:animate-none"
|
||||
>
|
||||
<HomeProjectsPanel
|
||||
{...props}
|
||||
onSelectProject={(server, directory) => {
|
||||
props.onSelectProject(server, directory)
|
||||
setState("open", false)
|
||||
}}
|
||||
onFocusServer={(server) => {
|
||||
props.onFocusServer(server)
|
||||
setState("open", false)
|
||||
}}
|
||||
onChooseProject={(server) => {
|
||||
setState("open", false)
|
||||
props.onChooseProject(server)
|
||||
}}
|
||||
onAddProjects={(server, directories) => {
|
||||
setState("open", false)
|
||||
props.onAddProjects(server, directories)
|
||||
}}
|
||||
onOpenProjectNewSession={(server, directory) => {
|
||||
setState("open", false)
|
||||
props.onOpenProjectNewSession(server, directory)
|
||||
}}
|
||||
/>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeProjectsPanel(props: HomeProjectsViewProps) {
|
||||
const [contextMenu, setContextMenu] = createStore({ open: undefined as string | undefined })
|
||||
const contextMenuProps = {
|
||||
contextMenuOpen: (id: string) => contextMenu.open === id,
|
||||
@@ -71,48 +148,77 @@ export function HomeProjectsView(props: HomeProjectsViewProps) {
|
||||
}
|
||||
return (
|
||||
<aside
|
||||
class={`
|
||||
class={
|
||||
props.dropdown
|
||||
? "flex max-h-[min(60dvh,calc(var(--kb-popper-content-available-height)-12px))] min-h-0 min-w-0 flex-col overflow-hidden"
|
||||
: `
|
||||
mt-6 flex min-h-0 min-w-0 flex-col gap-4 overflow-hidden
|
||||
lg:sticky lg:top-14 lg:mt-14 lg:h-[calc(100cqh-56px)] lg:self-start lg:pt-[52px]
|
||||
`}
|
||||
`
|
||||
}
|
||||
aria-label={props.language.t("home.projects")}
|
||||
onWheel={(event) => {
|
||||
if (event.target === event.currentTarget) return
|
||||
props.onWheel(event)
|
||||
}}
|
||||
>
|
||||
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
|
||||
<div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
|
||||
<Show when={props.servers.length === 1 && !(props.projects.length === 0 && props.recentlyClosed.length > 0)}>
|
||||
<Tooltip placement="bottom" value={props.language.t("home.project.add")}>
|
||||
<IconButton
|
||||
data-action="home-add-project"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted"
|
||||
icon={<Icon name="folder-add-left" />}
|
||||
disabled={props.serverHealth(props.servers[0])?.healthy === false}
|
||||
onClick={() => props.onChooseProject(props.servers[0])}
|
||||
aria-label={props.language.t("home.project.add")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={!props.dropdown}>
|
||||
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
|
||||
<div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
|
||||
<Show when={props.servers.length === 1 && !(props.projects.length === 0 && props.recentlyClosed.length > 0)}>
|
||||
<Tooltip placement="bottom" value={props.language.t("home.project.add")}>
|
||||
<IconButton
|
||||
data-action="home-add-project"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted"
|
||||
icon={<Icon name="folder-add-left" />}
|
||||
disabled={props.serverHealth(props.servers[0])?.healthy === false}
|
||||
onClick={() => props.onChooseProject(props.servers[0])}
|
||||
aria-label={props.language.t("home.project.add")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<ScrollView data-slot="home-projects-scroll" class="min-h-0 min-w-0 shrink">
|
||||
<Show when={props.dropdown && props.servers.length === 1}>
|
||||
<HomeProjectNavButton
|
||||
type="button"
|
||||
class="mb-1"
|
||||
data-selected={!props.selection.directory ? "" : undefined}
|
||||
onClick={() => props.onFocusServer(props.servers[0])}
|
||||
>
|
||||
<Icon name="folder" size="small" />
|
||||
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("home.projects.all")}</span>
|
||||
</HomeProjectNavButton>
|
||||
</Show>
|
||||
<Show
|
||||
when={props.servers.length > 1}
|
||||
fallback={
|
||||
<div class="pr-3">
|
||||
<div class={props.dropdown ? "" : "pr-3"}>
|
||||
<Show
|
||||
when={props.projects.length > 0}
|
||||
fallback={<HomeProjectEmpty {...props} server={props.servers[0]} items={props.recentlyClosed} />}
|
||||
>
|
||||
<HomeProjectList {...props} {...contextMenuProps} server={props.servers[0]} items={props.projects} />
|
||||
<Show when={props.dropdown}>
|
||||
<HomeProjectNavButton
|
||||
type="button"
|
||||
data-action="home-add-project-row"
|
||||
class="mt-1 disabled:opacity-60"
|
||||
disabled={props.serverHealth(props.servers[0])?.healthy === false}
|
||||
onClick={() => props.onChooseProject(props.servers[0])}
|
||||
>
|
||||
<Icon name="folder-add-left" size="small" />
|
||||
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("home.project.add")}</span>
|
||||
</HomeProjectNavButton>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-4 pr-3">
|
||||
<div class={`flex min-w-0 flex-col ${props.dropdown ? "gap-1" : "gap-4 pr-3"}`}>
|
||||
<For each={props.servers}>
|
||||
{(item) => {
|
||||
const projects = () => props.projectsForServer(item)
|
||||
@@ -157,7 +263,9 @@ export function HomeUtilityNav(props: {
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
return (
|
||||
<div class={`${props.class ?? ""} min-w-0 flex-col gap-1 pr-3`}>
|
||||
<div
|
||||
class={`${props.class ?? ""} min-w-0 flex-row justify-between gap-1 lg:flex-col lg:justify-start lg:pr-3 [&>button]:w-auto lg:[&>button]:w-full`}
|
||||
>
|
||||
<HomeProjectNavButton
|
||||
type="button"
|
||||
class="text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted"
|
||||
@@ -511,6 +619,7 @@ function HomeProjectRow(
|
||||
// does not focus that server and load its session index. Touch is
|
||||
// excluded so flick-scrolling the list cannot select rows.
|
||||
pointerDownSelected = undefined
|
||||
if (props.dropdown) return
|
||||
if (event.button !== 0 || event.pointerType === "touch") return
|
||||
if (!props.serverSelected) return
|
||||
pointerDownSelected = props.selected
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { Show } from "solid-js"
|
||||
import { createHomeController } from "./model"
|
||||
import { createHomeProjectsController } from "./projects/controller"
|
||||
import { HomeUtilityNav } from "./projects/view"
|
||||
@@ -9,6 +11,7 @@ import { createHomeSessionsController } from "./sessions/controller"
|
||||
import { HomeSessions } from "./sessions/region"
|
||||
|
||||
export function Home() {
|
||||
const mobile = createMediaQuery("(max-width: 767px)")
|
||||
const home = createHomeController()
|
||||
const projects = createHomeProjectsController(home)
|
||||
const sessions = createHomeSessionsController(home)
|
||||
@@ -17,12 +20,17 @@ export function Home() {
|
||||
return (
|
||||
<div
|
||||
class={`
|
||||
mx-2 mb-2 mt-[var(--shell-top-inset,8px)] min-h-0 flex-1 self-stretch overflow-hidden rounded-[10px]
|
||||
mx-2 mb-[var(--shell-bottom-inset,8px)] mt-[var(--shell-top-inset,8px)] flex min-h-0 flex-1 flex-col self-stretch overflow-hidden rounded-[10px]
|
||||
bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]
|
||||
`}
|
||||
>
|
||||
<Show when={mobile()}>
|
||||
<div class="relative z-40 -mb-3 shrink-0 px-3 pt-3">
|
||||
<HomeProjects projects={projects} scroll={scroll} dropdown />
|
||||
</div>
|
||||
</Show>
|
||||
<ScrollView
|
||||
class="h-full [container-type:size]"
|
||||
class="min-h-0 flex-1 [container-type:size]"
|
||||
thumbContainer={scroll.viewport.thumbTrack()}
|
||||
thumbHoverTarget={scroll.viewport.hoverTarget()}
|
||||
viewportRef={scroll.viewport.setViewport}
|
||||
@@ -31,20 +39,24 @@ export function Home() {
|
||||
>
|
||||
<div
|
||||
class={`
|
||||
mx-auto grid min-h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3
|
||||
lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6
|
||||
mx-auto grid min-h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)] gap-4 px-3
|
||||
max-md:grid-rows-[minmax(0,1fr)] lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6
|
||||
`}
|
||||
>
|
||||
<HomeProjects projects={projects} scroll={scroll} />
|
||||
<Show when={!mobile()}>
|
||||
<HomeProjects projects={projects} scroll={scroll} />
|
||||
</Show>
|
||||
<HomeSessions sessions={sessions} search={search} scroll={scroll} />
|
||||
<HomeUtilityNav
|
||||
class="flex lg:hidden"
|
||||
onOpenSettings={projects.utility.settings}
|
||||
onOpenHelp={projects.utility.help}
|
||||
language={projects.copy.language}
|
||||
/>
|
||||
</div>
|
||||
</ScrollView>
|
||||
<div class="hidden shrink-0 px-3 py-2 md:block lg:hidden">
|
||||
<HomeUtilityNav
|
||||
class="flex"
|
||||
onOpenSettings={projects.utility.settings}
|
||||
onOpenHelp={projects.utility.help}
|
||||
language={projects.copy.language}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
@media (max-width: 767px) {
|
||||
[data-component="home-session-row-container"][data-project-name="true"],
|
||||
[data-component="home-session-row-container"][data-project-name="true"] [data-component="home-session-row"],
|
||||
[data-component="home-session-row-container"][data-project-name="true"] [data-slot="home-session-editor"],
|
||||
[data-component="home-session-search-row"][data-project-name="true"] {
|
||||
height: 48px;
|
||||
padding-block: 0;
|
||||
}
|
||||
|
||||
[data-project-name="true"] [data-slot="home-session-labels"] {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
[data-project-name="true"] [data-slot="home-session-labels"] > * {
|
||||
max-width: 100%;
|
||||
flex: none;
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-compact);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { SessionTabAvatarView } from "@/shell/layout/session-tab-avatar"
|
||||
import { sessionLabel } from "@/session/title"
|
||||
import { shouldOpenSessionInBackground } from "./open"
|
||||
import "./view.css"
|
||||
import {
|
||||
HomeSessionStatusController,
|
||||
homeSessionSearchKey,
|
||||
@@ -96,10 +97,13 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
|
||||
class="min-h-0 min-w-0 flex-1 flex flex-col"
|
||||
aria-label={props.language.t("sidebar.project.recentSessions")}
|
||||
>
|
||||
<div class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-3 pt-6 lg:pt-12" onWheel={props.onWheel}>
|
||||
<div
|
||||
class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-1 pt-6 md:pb-3 lg:pt-12"
|
||||
onWheel={props.onWheel}
|
||||
>
|
||||
<HomeSessionSearch {...props} />
|
||||
<Show when={props.groups.length > 0 && props.canCreateSession}>
|
||||
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
|
||||
<div class="pointer-events-none absolute right-0 top-[68px] z-20 flex md:top-[84px] lg:top-[108px]">
|
||||
<Button
|
||||
data-action="home-new-session"
|
||||
variant="ghost-muted"
|
||||
@@ -113,18 +117,18 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="pointer-events-none sticky top-[84px] z-40 h-0 -mr-3 lg:top-[108px]">
|
||||
<div class="pointer-events-none sticky top-[68px] z-40 h-0 -mr-3 md:top-[84px] lg:top-[108px]">
|
||||
<div
|
||||
ref={props.onSetThumbTrack}
|
||||
data-component="home-session-scroll-track"
|
||||
class="relative ml-auto h-[calc(100cqh-84px)] w-3 lg:h-[calc(100cqh-108px)]"
|
||||
class="relative ml-auto h-[calc(100cqh-68px)] w-3 md:h-[calc(100cqh-84px)] lg:h-[calc(100cqh-108px)]"
|
||||
/>
|
||||
</div>
|
||||
<div class="-mr-3 min-h-[calc(100cqh-72px)] lg:min-h-[calc(100cqh-96px)]">
|
||||
<div class="-mr-3 min-h-[calc(100cqh-64px)] md:min-h-[calc(100cqh-72px)] lg:min-h-[calc(100cqh-96px)]">
|
||||
<Show
|
||||
when={!props.loading}
|
||||
fallback={
|
||||
<div class="pt-3">
|
||||
<div class="pt-1 md:pt-3">
|
||||
<HomeSessionSkeleton label={props.language.t("common.loading")} />
|
||||
</div>
|
||||
}
|
||||
@@ -138,7 +142,7 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div ref={props.onSetContent} class="flex flex-col pt-3 pr-3 pb-16">
|
||||
<div ref={props.onSetContent} class="flex flex-col pt-1 pr-3 pb-16 md:pt-3">
|
||||
{/* Index keeps group subtrees mounted when the group arrays are
|
||||
rebuilt, so store updates cannot recreate rows mid-gesture. */}
|
||||
<Index each={props.groups}>
|
||||
@@ -150,7 +154,9 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
|
||||
onSetRef={(element) => props.onSetHeader(group().id, element)}
|
||||
elevated={index === 0}
|
||||
/>
|
||||
<div class={`flex min-w-0 flex-col gap-px pt-4 ${index === props.groups.length - 1 ? "" : "mb-6"}`}>
|
||||
<div
|
||||
class={`flex min-w-0 flex-col gap-px pt-2 md:pt-4 ${index === props.groups.length - 1 ? "" : "mb-6"}`}
|
||||
>
|
||||
{/* Rows key by session ID: session.sync replaces the
|
||||
stored session object wholesale, so reference-keyed
|
||||
rows would be disposed mid-interaction whenever a
|
||||
@@ -235,7 +241,11 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
|
||||
absolute flex flex-col overflow-hidden rounded-[12px]
|
||||
bg-v2-background-bg-base shadow-[var(--v2-elevation-floating)]
|
||||
`}
|
||||
style={{ top: "-6px", left: "-6px", width: "calc(100% + 12px)" }}
|
||||
style={{
|
||||
top: "-6px",
|
||||
"inset-inline-start": "-6px",
|
||||
width: "calc(100% + 12px)",
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col pt-9">
|
||||
<div id={HOME_SESSION_SEARCH_RESULTS_ID} role="listbox" class="flex flex-col gap-4 pt-4">
|
||||
@@ -269,7 +279,7 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
|
||||
>
|
||||
{props.language.t("home.sessions.search.sessions")}
|
||||
</p>
|
||||
<ScrollView class="max-h-80" viewportRef={props.onSetSearchList}>
|
||||
<ScrollView class="max-h-[min(20rem,40dvh)]" viewportRef={props.onSetSearchList}>
|
||||
<div class="flex flex-col gap-px pb-2">
|
||||
<For each={props.searchResults}>
|
||||
{(record) => (
|
||||
@@ -291,7 +301,7 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
|
||||
</Show>
|
||||
<label
|
||||
class={`
|
||||
relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] py-1 pl-3 pr-2
|
||||
relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] py-1 ps-3 pe-2
|
||||
bg-v2-background-bg-layer-02/60 text-v2-icon-icon-muted transition-[background-color,box-shadow]
|
||||
duration-[120ms] ease-in-out hover:bg-v2-background-bg-layer-02 focus-within:bg-v2-background-bg-layer-02
|
||||
`}
|
||||
@@ -375,6 +385,7 @@ function HomeSessionSearchResultRow(
|
||||
id={`home-session-search-option-${key()}`}
|
||||
data-key={key()}
|
||||
data-component="home-session-search-row"
|
||||
data-project-name={!!showProjectName()}
|
||||
role="option"
|
||||
aria-selected={props.selected}
|
||||
class={`
|
||||
@@ -403,7 +414,7 @@ function HomeSessionSearchResultRow(
|
||||
record={props.record}
|
||||
revealProjectOnHover={!!showProjectName()}
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<div data-slot="home-session-labels" class="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<HomeSessionTitle title={title()} showProjectName={!!showProjectName()} search />
|
||||
<Show when={showProjectName()}>
|
||||
<HomeSessionProjectName name={props.record.projectName} search />
|
||||
@@ -423,8 +434,8 @@ function HomeSessionGroupHeader(props: {
|
||||
<div
|
||||
ref={props.onSetRef}
|
||||
class={`
|
||||
pointer-events-none sticky top-[84px] flex h-7 min-w-0 items-center justify-between
|
||||
bg-v2-background-bg-base pl-3 lg:top-[108px]
|
||||
pointer-events-none sticky top-[68px] flex h-7 min-w-0 items-center justify-between
|
||||
bg-v2-background-bg-base ps-1.5 md:ps-3 md:top-[84px] lg:top-[108px]
|
||||
`}
|
||||
classList={{ "home-session-group-header z-[5]": !!props.elevated, "z-10": !props.elevated }}
|
||||
>
|
||||
@@ -510,6 +521,7 @@ function HomeSessionRow(
|
||||
return (
|
||||
<div
|
||||
data-component="home-session-row-container"
|
||||
data-project-name={!!showProjectName()}
|
||||
data-session-id={props.record.session.id}
|
||||
class="group/session relative flex h-10 min-w-0 items-center rounded-[6px] outline-none focus:outline-none focus-visible:outline-none"
|
||||
classList={{ group: !!showProjectName() }}
|
||||
@@ -523,51 +535,56 @@ function HomeSessionRow(
|
||||
<Show
|
||||
when={!editor()}
|
||||
fallback={
|
||||
<div class="flex h-10 min-w-0 w-full flex-1 items-center gap-2 py-3 pl-3 pr-10">
|
||||
<div
|
||||
data-slot="home-session-editor"
|
||||
class="flex h-10 min-w-0 w-full flex-1 items-center gap-2 py-3 ps-1.5 pe-3 md:ps-3 md:pe-10"
|
||||
>
|
||||
<HomeSessionLeadingController
|
||||
server={props.server}
|
||||
isOpenTab={props.isOpenTab}
|
||||
record={props.record}
|
||||
revealProjectOnHover={false}
|
||||
/>
|
||||
<InlineInput
|
||||
data-component="home-session-rename"
|
||||
aria-label={props.language.t("common.rename")}
|
||||
dir="auto"
|
||||
value={editor()?.draft ?? ""}
|
||||
disabled={editor()?.renaming ?? false}
|
||||
class={`
|
||||
<div data-slot="home-session-labels" class="contents">
|
||||
<InlineInput
|
||||
data-component="home-session-rename"
|
||||
aria-label={props.language.t("common.rename")}
|
||||
dir="auto"
|
||||
value={editor()?.draft ?? ""}
|
||||
disabled={editor()?.renaming ?? false}
|
||||
class={`
|
||||
block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base
|
||||
[font-weight:530] field-sizing-content outline-none focus:outline-none focus-visible:outline-none
|
||||
${showProjectName() ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}
|
||||
`}
|
||||
style={{ "--inline-input-shadow": "none", "text-align": "start" }}
|
||||
onInput={(event) => {
|
||||
const draft = event.currentTarget.value
|
||||
props.setRowUI("editor", (value) => (value?.id === sessionID() ? { ...value, draft } : value))
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
// Enter and Escape during IME composition commit or cancel
|
||||
// the composition, not the rename. Safari can report the
|
||||
// composition-confirming keydown with isComposing false but
|
||||
// keyCode 229.
|
||||
if (event.isComposing || event.keyCode === 229) return
|
||||
if (event.key === "Enter") {
|
||||
style={{ "--inline-input-shadow": "none", "text-align": "start" }}
|
||||
onInput={(event) => {
|
||||
const draft = event.currentTarget.value
|
||||
props.setRowUI("editor", (value) => (value?.id === sessionID() ? { ...value, draft } : value))
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
// Enter and Escape during IME composition commit or cancel
|
||||
// the composition, not the rename. Safari can report the
|
||||
// composition-confirming keydown with isComposing false but
|
||||
// keyCode 229.
|
||||
if (event.isComposing || event.keyCode === 229) return
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void saveEditor()
|
||||
return
|
||||
}
|
||||
if (event.key !== "Escape") return
|
||||
event.preventDefault()
|
||||
void saveEditor()
|
||||
return
|
||||
}
|
||||
if (event.key !== "Escape") return
|
||||
event.preventDefault()
|
||||
closeEditor()
|
||||
requestAnimationFrame(() => rowButton()?.focus())
|
||||
}}
|
||||
onBlur={closeEditor}
|
||||
/>
|
||||
<Show when={showProjectName()}>
|
||||
<HomeSessionProjectName name={props.record.projectName} />
|
||||
</Show>
|
||||
closeEditor()
|
||||
requestAnimationFrame(() => rowButton()?.focus())
|
||||
}}
|
||||
onBlur={closeEditor}
|
||||
/>
|
||||
<Show when={showProjectName()}>
|
||||
<HomeSessionProjectName name={props.record.projectName} />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -578,7 +595,7 @@ function HomeSessionRow(
|
||||
aria-expanded={!!menu()}
|
||||
class={`
|
||||
flex h-10 min-w-0 w-full flex-1 shrink-0 cursor-default items-center gap-2 rounded-[6px] border-0
|
||||
bg-transparent py-3 pl-3 pr-10 text-left text-v2-text-text-muted [font-weight:530]
|
||||
bg-transparent py-3 ps-1.5 pe-3 md:ps-3 md:pe-10 text-start text-v2-text-text-muted [font-weight:530]
|
||||
transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out
|
||||
hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none
|
||||
`}
|
||||
@@ -641,10 +658,12 @@ function HomeSessionRow(
|
||||
record={props.record}
|
||||
revealProjectOnHover={!!showProjectName()}
|
||||
/>
|
||||
<HomeSessionTitle title={title()} showProjectName={!!showProjectName()} />
|
||||
<Show when={showProjectName()}>
|
||||
<HomeSessionProjectName name={props.record.projectName} />
|
||||
</Show>
|
||||
<div data-slot="home-session-labels" class="contents">
|
||||
<HomeSessionTitle title={title()} showProjectName={!!showProjectName()} />
|
||||
<Show when={showProjectName()}>
|
||||
<HomeSessionProjectName name={props.record.projectName} />
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
</Show>
|
||||
<Menu
|
||||
@@ -723,6 +742,7 @@ function HomeSessionTitle(props: { title: string; showProjectName: boolean; sear
|
||||
return (
|
||||
<span
|
||||
data-component="home-session-title"
|
||||
dir="auto"
|
||||
class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530]"
|
||||
classList={{
|
||||
"text-[13px] leading-4 tracking-[-0.04px]": !!props.search,
|
||||
@@ -738,6 +758,8 @@ function HomeSessionTitle(props: { title: string; showProjectName: boolean; sear
|
||||
function HomeSessionProjectName(props: { name: string; search?: boolean }) {
|
||||
return (
|
||||
<span
|
||||
data-component="home-session-project-name"
|
||||
dir="auto"
|
||||
class="min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-muted [font-weight:440]"
|
||||
classList={{ "text-[13px] leading-4 tracking-[-0.04px]": !!props.search }}
|
||||
>
|
||||
@@ -779,7 +801,7 @@ function HomeSessionsEmpty(props: { onNewSession?: () => void; language: ReturnT
|
||||
function HomeSessionSkeleton(props: { label: string }) {
|
||||
return (
|
||||
<div class="flex min-w-0 flex-col gap-4">
|
||||
<div class="flex h-7 min-w-0 items-center justify-between px-4">
|
||||
<div class="flex h-7 min-w-0 items-center justify-between ps-1.5 pe-4 md:ps-4">
|
||||
<div class={HOME_SECTION_LABEL}>{props.label}</div>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-col gap-px" aria-hidden="true">
|
||||
|
||||
@@ -73,7 +73,7 @@ export default function NewSessionPage(props: { draftId: string }) {
|
||||
<div class="relative size-full overflow-hidden flex flex-col">
|
||||
{suspendUntilPromptReady()}
|
||||
<NewSessionStatus visible={settings.visibility.status()} />
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-2 pt-[var(--shell-top-inset,8px)]">
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
|
||||
<NewSessionView composer={model} project={project} workspace={workspace} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -643,6 +643,7 @@ export const dict = {
|
||||
"home.empty.description": "Get started by opening a local project",
|
||||
"home.title": "Home",
|
||||
"home.projects": "Projects",
|
||||
"home.projects.all": "All projects",
|
||||
"home.project.add": "Add project",
|
||||
"home.recentlyClosed": "Recently closed",
|
||||
"home.server.collapse": "Collapse server projects",
|
||||
@@ -799,6 +800,7 @@ export const dict = {
|
||||
"terminal.connectTicket.statusError": "PTY connect ticket failed with {{status}}",
|
||||
|
||||
"titlebar.update": "Update",
|
||||
"titlebar.tabs": "Tabs",
|
||||
"titlebar.updateVersion": "Update {{version}}",
|
||||
|
||||
"common.closeTab": "Close tab",
|
||||
|
||||
@@ -154,7 +154,7 @@ function PendingSessionState(props: { sessionID: string }) {
|
||||
|
||||
function SessionStatePanel(props: ParentProps) {
|
||||
return (
|
||||
<div class="flex min-h-0 flex-1 px-2 pb-2 pt-[var(--shell-top-inset,8px)]">
|
||||
<div class="flex min-h-0 flex-1 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
|
||||
<SessionPanelFrame raised>{props.children}</SessionPanelFrame>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -157,7 +157,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
return (
|
||||
<>
|
||||
<SessionHeader />
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-2 pt-[var(--shell-top-inset,8px)]">
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
|
||||
<div ref={screen.panel.ref} class="relative flex-1 min-h-0 flex flex-col md:flex-row gap-2">
|
||||
<div
|
||||
classList={{
|
||||
|
||||
@@ -3,8 +3,8 @@ import type { ParentProps } from "solid-js"
|
||||
export function SessionRouteFrame(props: ParentProps<{ padded?: boolean }>) {
|
||||
return (
|
||||
<div
|
||||
class="relative flex size-full flex-col overflow-hidden"
|
||||
classList={{ "px-2 pb-2 pt-[var(--shell-top-inset,8px)]": props.padded }}
|
||||
class="relative flex size-full flex-col"
|
||||
classList={{ "px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]": props.padded }}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,13 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.settings-screen {
|
||||
--settings-mobile-inner-inset: 8px;
|
||||
padding-block: var(--settings-top-inset, var(--shell-top-inset, 8px)) var(--shell-bottom-inset, 8px);
|
||||
}
|
||||
}
|
||||
|
||||
.settings-screen
|
||||
> .settings[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
|
||||
> [data-slot="tabs-v2-list"] {
|
||||
@@ -49,6 +56,7 @@
|
||||
|
||||
.settings-screen .settings-tab-body {
|
||||
padding-inline: 0;
|
||||
padding-bottom: var(--settings-bottom-inset, 0px);
|
||||
}
|
||||
|
||||
.settings-nav {
|
||||
@@ -59,6 +67,26 @@
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.settings-mobile-nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settings-mobile-menu-trigger {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-mobile-menu-trigger > span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.settings-mobile-menu[data-component="menu-v2-content"] {
|
||||
max-width: calc(100vw - 32px);
|
||||
max-height: var(--kb-popper-content-available-height);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.settings-nav-footer {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
@@ -291,36 +319,6 @@
|
||||
background-color: var(--v2-background-bg-layer-01);
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.settings-screen .settings-nav {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settings-screen > .settings > .settings-panel {
|
||||
padding-inline-end: 16px;
|
||||
}
|
||||
|
||||
.settings-screen .settings-tab-header {
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
.settings[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
|
||||
> [data-slot="tabs-v2-list"] {
|
||||
width: 144px;
|
||||
min-width: 144px;
|
||||
padding-inline: 8px;
|
||||
}
|
||||
|
||||
.settings-screen
|
||||
> .settings[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
|
||||
> [data-slot="tabs-v2-list"] {
|
||||
width: 160px;
|
||||
min-width: 160px;
|
||||
padding-block: 24px;
|
||||
padding-inline: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@container settings-screen (min-width: 800px) and (max-width: 1047px) {
|
||||
.settings-screen > .settings {
|
||||
padding-inline: 24px;
|
||||
@@ -337,25 +335,60 @@
|
||||
}
|
||||
|
||||
@container settings-screen (max-width: 799px) {
|
||||
.settings-screen .settings-nav {
|
||||
width: 100%;
|
||||
.settings-screen > .settings[data-component="tabs-v2"] {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings-mobile-nav {
|
||||
position: relative;
|
||||
z-index: 11;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 8px var(--settings-mobile-inner-inset, 16px);
|
||||
border-bottom: 0.5px solid var(--v2-border-border-muted);
|
||||
background: var(--v2-background-bg-deep);
|
||||
}
|
||||
|
||||
.settings-mobile-nav::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
inset-block-start: 100%;
|
||||
height: 1px;
|
||||
background: var(--v2-background-bg-deep);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.settings-mobile-nav .settings-back {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.settings-screen > .settings > .settings-panel {
|
||||
padding-inline-end: 16px;
|
||||
min-height: 0;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.settings-screen .settings-tab-header,
|
||||
.settings-screen .settings-tab-body {
|
||||
padding-inline: var(--settings-mobile-inner-inset, 16px);
|
||||
}
|
||||
|
||||
.settings-screen .settings-tab-header {
|
||||
padding-top: 24px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.settings-screen .settings-tab-title,
|
||||
.settings-screen .settings-section-title {
|
||||
line-height: var(--line-height-base);
|
||||
}
|
||||
|
||||
.settings-screen
|
||||
> .settings[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
|
||||
> [data-slot="tabs-v2-list"] {
|
||||
width: 160px;
|
||||
min-width: 160px;
|
||||
padding-block: 24px;
|
||||
padding-inline: 12px;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -636,6 +669,15 @@
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.settings-models [data-component="settings-row"] {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.settings-models [data-slot="settings-row-control"] {
|
||||
width: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.settings-models [data-slot="settings-row-title"] {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { Component, createEffect, createMemo, createSignal, onCleanup, onMount, startTransition } from "solid-js"
|
||||
import {
|
||||
Component,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
For,
|
||||
Show,
|
||||
onCleanup,
|
||||
onMount,
|
||||
startTransition,
|
||||
} from "solid-js"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Menu } from "@opencode-ai/ui/menu"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { SettingsGeneral } from "./general/general"
|
||||
@@ -23,6 +35,25 @@ import { useCommand } from "@/shell/commands/command"
|
||||
import { useSettingsSurface } from "./surface"
|
||||
import "@/settings/settings.css"
|
||||
|
||||
const sections = [
|
||||
[
|
||||
{ value: "general", icon: "sliders", label: "settings.tab.preferences" },
|
||||
{ value: "appearance", icon: "appearance", label: "settings.general.section.appearance" },
|
||||
{ value: "notifications", icon: "notifications", label: "settings.tab.notifications" },
|
||||
{ value: "shortcuts", icon: "keyboard", label: "settings.tab.shortcuts" },
|
||||
],
|
||||
[
|
||||
{ value: "servers", icon: "server", label: "status.popover.tab.servers" },
|
||||
{ value: "projects", icon: "folder", label: "settings.tab.projects" },
|
||||
{ value: "workspaces", icon: "workspace-isolated", label: "settings.tab.workspaces" },
|
||||
],
|
||||
[
|
||||
{ value: "providers", icon: "providers", label: "settings.providers.title" },
|
||||
{ value: "models", icon: "models", label: "settings.models.title" },
|
||||
{ value: "extensions", icon: "extensions", label: "settings.tab.extensions" },
|
||||
],
|
||||
] as const
|
||||
|
||||
export const SettingsScreen: Component<{
|
||||
defaultValue?: string
|
||||
}> = (props) => {
|
||||
@@ -103,6 +134,45 @@ export const SettingsScreen: Component<{
|
||||
onChange={(value) => void startTransition(() => setTab(value))}
|
||||
class="settings"
|
||||
>
|
||||
<div class="settings-mobile-nav">
|
||||
<button type="button" class="settings-back" onClick={surface.close}>
|
||||
<Icon name="arrow-left" size="small" class="settings-back-icon" />
|
||||
<span>{language.t("settings.backToApp")}</span>
|
||||
</button>
|
||||
<Menu placement="bottom-end" gutter={8}>
|
||||
<Menu.Trigger as={Button} size="normal" variant="outline" class="settings-mobile-menu-trigger">
|
||||
<span>
|
||||
{language.t(
|
||||
sections.flat().find((section) => section.value === tab())?.label ?? "settings.tab.preferences",
|
||||
)}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Content class="settings-mobile-menu" onEscapeKeyDown={(event) => event.stopPropagation()}>
|
||||
<Menu.RadioGroup value={tab()} onChange={(value) => void startTransition(() => setTab(value))}>
|
||||
<For each={sections}>
|
||||
{(group, index) => (
|
||||
<>
|
||||
<Show when={index() > 0}>
|
||||
<Menu.Separator />
|
||||
</Show>
|
||||
<For each={group}>
|
||||
{(section) => (
|
||||
<Menu.RadioItem value={section.value} closeOnSelect>
|
||||
<Icon name={section.icon} />
|
||||
{language.t(section.label)}
|
||||
</Menu.RadioItem>
|
||||
)}
|
||||
</For>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</Menu.RadioGroup>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
</div>
|
||||
<Tabs.List>
|
||||
<div class="settings-nav">
|
||||
<button type="button" class="settings-back" onClick={surface.close}>
|
||||
@@ -110,57 +180,20 @@ export const SettingsScreen: Component<{
|
||||
<span>{language.t("settings.backToApp")}</span>
|
||||
</button>
|
||||
<div class="flex flex-col gap-4 w-full">
|
||||
{/* Group 1: Preferences */}
|
||||
<div class="flex flex-col gap-1 w-full">
|
||||
<Tabs.Trigger value="general">
|
||||
<Icon name="sliders" />
|
||||
{language.t("settings.tab.preferences")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="appearance">
|
||||
<Icon name="appearance" />
|
||||
{language.t("settings.general.section.appearance")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="notifications">
|
||||
<Icon name="notifications" />
|
||||
{language.t("settings.tab.notifications")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="shortcuts">
|
||||
<Icon name="keyboard" />
|
||||
{language.t("settings.tab.shortcuts")}
|
||||
</Tabs.Trigger>
|
||||
</div>
|
||||
|
||||
{/* Group 2: Environment & Workspaces */}
|
||||
<div class="flex flex-col gap-1 w-full">
|
||||
<Tabs.Trigger value="servers">
|
||||
<Icon name="server" />
|
||||
{language.t("status.popover.tab.servers")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="projects">
|
||||
<Icon name="folder" />
|
||||
{language.t("settings.tab.projects")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="workspaces">
|
||||
<Icon name="workspace-isolated" />
|
||||
{language.t("settings.tab.workspaces")}
|
||||
</Tabs.Trigger>
|
||||
</div>
|
||||
|
||||
{/* Group 3: Capabilities & Extensions */}
|
||||
<div class="flex flex-col gap-1 w-full">
|
||||
<Tabs.Trigger value="providers">
|
||||
<Icon name="providers" />
|
||||
{language.t("settings.providers.title")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="models">
|
||||
<Icon name="models" />
|
||||
{language.t("settings.models.title")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="extensions">
|
||||
<Icon name="extensions" />
|
||||
{language.t("settings.tab.extensions")}
|
||||
</Tabs.Trigger>
|
||||
</div>
|
||||
<For each={sections}>
|
||||
{(group) => (
|
||||
<div class="flex flex-col gap-1 w-full">
|
||||
<For each={group}>
|
||||
{(section) => (
|
||||
<Tabs.Trigger value={section.value}>
|
||||
<Icon name={section.icon} />
|
||||
{language.t(section.label)}
|
||||
</Tabs.Trigger>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-nav-footer">
|
||||
|
||||
@@ -35,7 +35,7 @@ export function AppRoutes() {
|
||||
<SessionRouteFrame>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div class="flex min-h-0 flex-1 px-2 pb-2 pt-[var(--shell-top-inset,8px)]">
|
||||
<div class="flex min-h-0 flex-1 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
|
||||
<SessionPanelFrame raised />
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export default function Layout(props: ParentProps) {
|
||||
tabsMount: undefined as HTMLElement | undefined,
|
||||
})
|
||||
const verticalTabs = () => preferences.appearance.tabLayout() === "vertical" && !mobile()
|
||||
const bottomTitlebar = () => mobile() && preferences.general.mobileTitlebarPosition() === "bottom"
|
||||
|
||||
const update: TitlebarUpdate = {
|
||||
get version() {
|
||||
@@ -41,15 +42,13 @@ export default function Layout(props: ParentProps) {
|
||||
<div
|
||||
class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text"
|
||||
style={{
|
||||
"padding-top": "env(safe-area-inset-top, 0px)",
|
||||
"padding-bottom": "env(safe-area-inset-bottom, 0px)",
|
||||
// Native Windows chrome supplies the gap; retain paint clearance for the panels' outer outlines.
|
||||
"--shell-top-inset":
|
||||
platform.platform === "desktop" &&
|
||||
platform.os === "windows" &&
|
||||
!(mobile() && preferences.general.mobileTitlebarPosition() === "bottom")
|
||||
"--shell-top-inset": bottomTitlebar()
|
||||
? "max(0px, calc(8px - env(safe-area-inset-top, 0px)))"
|
||||
: platform.platform === "desktop" && platform.os === "windows"
|
||||
? "1px"
|
||||
: "8px",
|
||||
"--shell-bottom-inset": bottomTitlebar() ? "8px" : "max(0px, calc(8px - env(safe-area-inset-bottom, 0px)))",
|
||||
}}
|
||||
>
|
||||
<Titlebar
|
||||
@@ -66,8 +65,11 @@ export default function Layout(props: ParentProps) {
|
||||
<aside
|
||||
ref={(element) => setState("tabsMount", element)}
|
||||
data-slot="vertical-tabs-sidebar"
|
||||
class="relative flex min-h-0 shrink-0 flex-col bg-v2-background-bg-deep px-2.5 pb-2 pt-[var(--shell-top-inset,8px)]"
|
||||
style={{ width: `${state.tabsWidth}px` }}
|
||||
class="relative flex min-h-0 shrink-0 flex-col bg-v2-background-bg-deep px-2.5 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]"
|
||||
style={{
|
||||
width: `${state.tabsWidth}px`,
|
||||
"padding-bottom": "max(8px, env(safe-area-inset-bottom, 0px))",
|
||||
}}
|
||||
>
|
||||
<ResizeHandle
|
||||
class="-end-2"
|
||||
@@ -80,7 +82,15 @@ export default function Layout(props: ParentProps) {
|
||||
</aside>
|
||||
</Show>
|
||||
{/* Size containment collapses percentage-height descendants in WebKit. */}
|
||||
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-content">
|
||||
<main
|
||||
class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-content"
|
||||
style={{
|
||||
"padding-top": bottomTitlebar() ? "env(safe-area-inset-top, 0px)" : "0px",
|
||||
"padding-bottom": bottomTitlebar() || settings.store.open ? "0px" : "env(safe-area-inset-bottom, 0px)",
|
||||
"--settings-bottom-inset": bottomTitlebar() ? "40px" : "env(safe-area-inset-bottom, 0px)",
|
||||
"--settings-top-inset": mobile() && !bottomTitlebar() ? "0px" : "var(--shell-top-inset, 8px)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="flex size-full min-h-0 min-w-0 flex-col"
|
||||
hidden={settings.store.open}
|
||||
|
||||
@@ -57,9 +57,10 @@
|
||||
margin-block: auto;
|
||||
}
|
||||
|
||||
[data-titlebar-tab]:is(:hover, :has(> [data-slot="tab-link"]:focus-visible)):not([data-state="pressed"]):not(
|
||||
[data-dragging="true"]
|
||||
):not([data-editing="true"]) {
|
||||
[data-titlebar-tab]:not(:where([data-slot="mobile-tabs-drawer"] *)):is(
|
||||
:hover,
|
||||
:has(> [data-slot="tab-link"]:focus-visible)
|
||||
):not([data-state="pressed"]):not([data-dragging="true"]):not([data-editing="true"]) {
|
||||
--tab-base: var(--v2-background-bg-layer-02);
|
||||
--tab-overlay: var(--v2-overlay-simple-overlay-hover);
|
||||
}
|
||||
@@ -183,6 +184,36 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
[data-titlebar-tab] [data-slot="tab-close"] {
|
||||
background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base);
|
||||
}
|
||||
|
||||
[data-titlebar-tab]:not([data-editing="true"]) [data-slot="tab-link"],
|
||||
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-link"] {
|
||||
--tab-title-fade-offset: 24px;
|
||||
--tab-title-fade-direction: to right;
|
||||
-webkit-mask-image: linear-gradient(
|
||||
var(--tab-title-fade-direction),
|
||||
black 0,
|
||||
black calc(100% - var(--tab-title-fade-offset) - 16px),
|
||||
transparent calc(100% - var(--tab-title-fade-offset)),
|
||||
transparent 100%
|
||||
);
|
||||
mask-image: linear-gradient(
|
||||
var(--tab-title-fade-direction),
|
||||
black 0,
|
||||
black calc(100% - var(--tab-title-fade-offset) - 16px),
|
||||
transparent calc(100% - var(--tab-title-fade-offset)),
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
[data-titlebar-tab]:dir(rtl) [data-slot="tab-link"] {
|
||||
--tab-title-fade-direction: to left;
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 64px) {
|
||||
[data-titlebar-tab]:not([data-orientation="vertical"]) [data-titlebar-tab-link] {
|
||||
justify-content: center;
|
||||
@@ -190,6 +221,17 @@
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
[data-titlebar-tab]:not([data-orientation="vertical"]):not([data-editing="true"]) [data-slot="tab-link"],
|
||||
[data-titlebar-tab][data-title-overflow="true"]:not([data-orientation="vertical"]):not([data-editing="true"]):dir(
|
||||
rtl
|
||||
)
|
||||
[data-slot="tab-link"] {
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-titlebar-tab]:not([data-orientation="vertical"]) [data-titlebar-tab-title] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { HoverCard } from "@kobalte/core/hover-card"
|
||||
import { createSignal, Show, type JSXElement } from "solid-js"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import "./tab-popover.css"
|
||||
|
||||
// Initial hover delay before the preview appears, per design.
|
||||
@@ -28,6 +29,7 @@ export function TabPreviewPopover(props: {
|
||||
orientation?: "horizontal" | "vertical"
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const mobile = createMediaQuery("(max-width: 767px)")
|
||||
let triggerEl: HTMLDivElement | undefined
|
||||
// When opened during a rapid tab-hopping streak, this preview appears and
|
||||
// disappears instantly (no repeated enter/exit animation) — only the first,
|
||||
@@ -46,7 +48,7 @@ export function TabPreviewPopover(props: {
|
||||
|
||||
return (
|
||||
<HoverCard
|
||||
open={props.open}
|
||||
open={props.open && !mobile()}
|
||||
onOpenChange={handleOpenChange}
|
||||
openDelay={resolveOpenDelay()}
|
||||
closeDelay={CLOSE_DELAY}
|
||||
|
||||
@@ -2,6 +2,155 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
[data-slot="mobile-tabs-trigger"][aria-expanded="true"] {
|
||||
background:
|
||||
linear-gradient(var(--v2-overlay-simple-overlay-hover), var(--v2-overlay-simple-overlay-hover)),
|
||||
var(--v2-background-bg-layer-02);
|
||||
}
|
||||
|
||||
[data-action="mobile-tabs-home"][data-state="pressed"] {
|
||||
background:
|
||||
linear-gradient(var(--v2-overlay-simple-overlay-pressed), var(--v2-overlay-simple-overlay-pressed)),
|
||||
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;
|
||||
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);
|
||||
}
|
||||
|
||||
[data-slot="mobile-tabs-drawer-list"] {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
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;
|
||||
}
|
||||
|
||||
[data-slot="mobile-tabs-drawer"] [data-slot="vertical-tabs-scroll"] {
|
||||
min-height: 0;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
[data-slot="mobile-tabs-drawer"] [data-titlebar-tab] {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
[data-slot="mobile-tabs-drawer"] [data-titlebar-tab] [data-slot="tab-close"] {
|
||||
inset-inline-end: 0;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
[data-slot="mobile-tabs-drawer"] [data-slot="tab-close"] button {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-slot="mobile-tabs-drawer"] [data-titlebar-tab] [data-slot="tab-link"] {
|
||||
padding-inline-end: 38px;
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
}
|
||||
|
||||
[data-slot="mobile-tabs-drawer"] [data-titlebar-tab] [data-slot="tab-title"] {
|
||||
color: var(--v2-text-text-base);
|
||||
font-weight: 530;
|
||||
}
|
||||
|
||||
[data-slot="mobile-tabs-drawer"] [data-titlebar-tab] [data-slot="tab-project"] {
|
||||
color: var(--v2-text-text-faint);
|
||||
font-weight: 440;
|
||||
}
|
||||
|
||||
[data-slot="titlebar-tab-item"] a {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,11 @@ 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 { sessionLabel } from "@/session/title"
|
||||
import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
|
||||
import { projectForSession } from "@/shell/layout/helpers"
|
||||
import { useSettingsDialog } from "@/settings/command"
|
||||
|
||||
const titlebarHeight = 36
|
||||
const windowsTitlebarHeight = 44 // Includes the content inset; matches the native Windows overlay.
|
||||
@@ -47,6 +52,7 @@ export function Titlebar(props: {
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const openSettings = useSettingsDialog()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const mobile = createMediaQuery("(max-width: 767px)")
|
||||
@@ -138,6 +144,14 @@ export function Titlebar(props: {
|
||||
"order-last": bottom(),
|
||||
}}
|
||||
style={{
|
||||
height:
|
||||
platform.platform === "web"
|
||||
? bottom()
|
||||
? "calc(28px + max(8px, env(safe-area-inset-bottom, 0px)))"
|
||||
: "calc(28px + max(8px, env(safe-area-inset-top, 0px)))"
|
||||
: undefined,
|
||||
"padding-top": bottom() ? "0px" : "env(safe-area-inset-top, 0px)",
|
||||
"padding-bottom": bottom() ? "env(safe-area-inset-bottom, 0px)" : "0px",
|
||||
"min-height": minHeight(),
|
||||
// Keep native macOS traffic lights clear even when the desktop window is narrow.
|
||||
"padding-left": macTrafficLights() ? `${macTrafficLightsBaseWidth / zoom()}px` : 0,
|
||||
@@ -336,116 +350,283 @@ export function Titlebar(props: {
|
||||
].filter((v) => v !== undefined)
|
||||
})
|
||||
|
||||
const [mobileTabs, setMobileTabs] = createStore({ open: false, settings: false })
|
||||
const currentProject = createMemo(() => {
|
||||
const tab = currentTab()
|
||||
const value = session()
|
||||
if (!tab || !value) return
|
||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
|
||||
return projectForSession(value, conn ? global.ensureServerCtx(conn).projects.list() : [])
|
||||
})
|
||||
const currentTitle = () => {
|
||||
const tab = currentTab()
|
||||
if (!tab) return language.t("home.title")
|
||||
if (tab.type === "draft") return language.t("command.session.new")
|
||||
const value = session()
|
||||
return value ? sessionLabel(value) : (tabs.info[tabKey(tab)]?.title ?? language.t("command.session.new"))
|
||||
}
|
||||
createEffect(() => {
|
||||
path()
|
||||
mobile()
|
||||
setMobileTabs("open", false)
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
class="h-full flex-1 overflow-hidden flex flex-row items-center gap-1.5 px-2 md:pr-3"
|
||||
classList={{
|
||||
"pt-2": !bottom() && !windows(),
|
||||
"pb-2": bottom(),
|
||||
"pt-[max(0px,calc(8px-env(safe-area-inset-top,0px)))]": !bottom() && !windows(),
|
||||
"pb-[max(0px,calc(8px-env(safe-area-inset-bottom,0px)))]": bottom(),
|
||||
"md:pl-2": macTrafficLights(),
|
||||
"md:pl-4": !macTrafficLights(),
|
||||
}}
|
||||
>
|
||||
<ChannelIndicator debugTools={props.debugTools} />
|
||||
<Show when={!mobile()}>
|
||||
<ChannelIndicator debugTools={props.debugTools} />
|
||||
</Show>
|
||||
<Show when={windows() || linux()}>
|
||||
<WindowsAppMenu command={command} platform={platform} />
|
||||
</Show>
|
||||
<Tooltip
|
||||
placement="bottom"
|
||||
value={
|
||||
<>
|
||||
{language.t("home.title")}
|
||||
<Keybind keys={command.keybindParts("home.toggle")} variant="neutral" />
|
||||
</>
|
||||
}
|
||||
class="shrink-0"
|
||||
>
|
||||
<IconButton
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="!w-9 shrink-0"
|
||||
icon={<Icon name="grid-plus" />}
|
||||
state={layout.route().type === "home" ? "pressed" : undefined}
|
||||
onClick={toggleHome}
|
||||
aria-label={language.t("home.title")}
|
||||
aria-pressed={layout.route().type === "home"}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Show when={!mobile()}>
|
||||
<Tooltip
|
||||
placement="bottom"
|
||||
value={
|
||||
<>
|
||||
{language.t("home.title")}
|
||||
<Keybind keys={command.keybindParts("home.toggle")} variant="neutral" />
|
||||
</>
|
||||
}
|
||||
class="shrink-0"
|
||||
>
|
||||
<IconButton
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="!w-9 shrink-0"
|
||||
icon={<Icon name="grid-plus" />}
|
||||
state={layout.route().type === "home" ? "pressed" : undefined}
|
||||
onClick={toggleHome}
|
||||
aria-label={language.t("home.title")}
|
||||
aria-pressed={layout.route().type === "home"}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={props.verticalTabs}
|
||||
when={!mobile()}
|
||||
fallback={
|
||||
<>
|
||||
<TitlebarTabStrip
|
||||
tabs={tabsStore}
|
||||
currentTab={currentTab()}
|
||||
onNavigate={(tab, el) => {
|
||||
tabs.select(tab)
|
||||
el?.scrollIntoView({ behavior: "instant" })
|
||||
}}
|
||||
onClose={(tab) => {
|
||||
const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
|
||||
if (index !== -1) tabsStoreActions.closeTab(index)
|
||||
}}
|
||||
onReorder={(keys) => tabsStoreActions.reorder(keys)}
|
||||
/>
|
||||
<Tooltip
|
||||
placement="bottom"
|
||||
value={
|
||||
<>
|
||||
{language.t("command.session.new")}
|
||||
<Keybind keys={newTabTooltipKeybind(command)} variant="neutral" />
|
||||
</>
|
||||
}
|
||||
<Drawer
|
||||
open={mobileTabs.open}
|
||||
onOpenChange={(open) => setMobileTabs("open", open)}
|
||||
onContentPresentChange={(present) => {
|
||||
if (present || !mobileTabs.settings) return
|
||||
setMobileTabs("settings", false)
|
||||
openSettings()
|
||||
}}
|
||||
side="bottom"
|
||||
>
|
||||
<Drawer.Trigger
|
||||
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")}
|
||||
>
|
||||
<IconButton
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="shrink-0"
|
||||
icon={<Icon name="plus" />}
|
||||
onClick={openNewTab}
|
||||
aria-label={language.t("command.session.new")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{(vertical) => (
|
||||
<Show when={vertical().mount} keyed>
|
||||
{(mount) => (
|
||||
<Portal mount={mount}>
|
||||
<TitlebarTabStrip
|
||||
orientation="vertical"
|
||||
tabs={tabsStore}
|
||||
currentTab={currentTab()}
|
||||
onNavigate={(tab, el) => {
|
||||
tabs.select(tab)
|
||||
el?.scrollIntoView({ behavior: "instant", block: "nearest" })
|
||||
}}
|
||||
onClose={(tab) => {
|
||||
const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
|
||||
if (index !== -1) tabsStoreActions.closeTab(index)
|
||||
}}
|
||||
onReorder={(keys) => tabsStoreActions.reorder(keys)}
|
||||
/>
|
||||
<Show when={currentTab()} fallback={<Icon name="grid-plus" class="shrink-0" />}>
|
||||
{(tab) => (
|
||||
<span
|
||||
data-slot="project-avatar-slot"
|
||||
class="flex size-4 shrink-0 items-center justify-center"
|
||||
>
|
||||
<Show
|
||||
when={session()}
|
||||
fallback={
|
||||
tab().type === "draft" ? (
|
||||
<Icon name="edit" />
|
||||
) : (
|
||||
<span
|
||||
class="block size-4 rounded-[3px] border border-v2-border-border-muted"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
{(value) => (
|
||||
<SessionTabAvatar
|
||||
project={currentProject()}
|
||||
directory={value().location.directory}
|
||||
sessionId={value().id}
|
||||
server={tab().server}
|
||||
revealProjectOnHover={false}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
<span dir="auto" class="min-w-0 flex-1 truncate text-start">
|
||||
{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>
|
||||
<TitlebarTabStrip
|
||||
orientation="vertical"
|
||||
tabs={tabsStore}
|
||||
currentTab={currentTab()}
|
||||
onNavigate={(tab) => {
|
||||
tabs.select(tab)
|
||||
setMobileTabs("open", false)
|
||||
}}
|
||||
onClose={(tab) => {
|
||||
const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
|
||||
if (index !== -1) tabsStoreActions.closeTab(index)
|
||||
}}
|
||||
onReorder={(keys) => tabsStoreActions.reorder(keys)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-action="vertical-tabs-new-session"
|
||||
class="mt-1 flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] px-1.5 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base"
|
||||
onClick={openNewTab}
|
||||
aria-label={language.t("command.session.new")}
|
||||
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={() => {
|
||||
openNewTab()
|
||||
setMobileTabs("open", false)
|
||||
}}
|
||||
>
|
||||
<Icon name="plus" />
|
||||
{language.t("command.session.new")}
|
||||
</button>
|
||||
</Portal>
|
||||
)}
|
||||
</Show>
|
||||
)}
|
||||
<div
|
||||
class="flex shrink-0 flex-col gap-1 border-t border-v2-border-border-muted pt-2"
|
||||
data-corvu-no-drag
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-action="mobile-tabs-home"
|
||||
data-state={layout.route().type === "home" ? "pressed" : undefined}
|
||||
aria-current={layout.route().type === "home" ? "page" : undefined}
|
||||
class="flex h-7 w-full items-center gap-2 rounded-[6px] px-2 text-[13px] leading-4 text-v2-text-text-faint data-[state=pressed]:text-v2-text-text-base focus-visible:outline-none"
|
||||
onClick={() => {
|
||||
if (layout.route().type !== "home") toggleHome()
|
||||
setMobileTabs("open", false)
|
||||
}}
|
||||
>
|
||||
<Icon name="grid-plus" />
|
||||
{language.t("home.title")}
|
||||
</button>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
data-action="mobile-tabs-settings"
|
||||
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-faint hover:bg-v2-background-bg-layer-02 focus-visible:outline-none focus-visible:bg-v2-background-bg-layer-02"
|
||||
onClick={() => setMobileTabs({ open: false, settings: true })}
|
||||
>
|
||||
<Icon name="settings-gear" size="small" />
|
||||
{language.t("sidebar.settings")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-action="mobile-tabs-help"
|
||||
class="flex h-7 shrink-0 items-center gap-2 rounded-[6px] px-2 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 focus-visible:outline-none focus-visible:bg-v2-background-bg-layer-02"
|
||||
onClick={() => {
|
||||
setMobileTabs("open", false)
|
||||
platform.openExternal("https://opencode.ai/desktop-feedback")
|
||||
}}
|
||||
>
|
||||
<Icon name="help" size="small" />
|
||||
{language.t("sidebar.help")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Drawer.Content>
|
||||
</Drawer.Portal>
|
||||
</Drawer>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={props.verticalTabs}
|
||||
fallback={
|
||||
<>
|
||||
<TitlebarTabStrip
|
||||
tabs={tabsStore}
|
||||
currentTab={currentTab()}
|
||||
onNavigate={(tab, el) => {
|
||||
tabs.select(tab)
|
||||
el?.scrollIntoView({ behavior: "instant" })
|
||||
}}
|
||||
onClose={(tab) => {
|
||||
const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
|
||||
if (index !== -1) tabsStoreActions.closeTab(index)
|
||||
}}
|
||||
onReorder={(keys) => tabsStoreActions.reorder(keys)}
|
||||
/>
|
||||
<Tooltip
|
||||
placement="bottom"
|
||||
value={
|
||||
<>
|
||||
{language.t("command.session.new")}
|
||||
<Keybind keys={newTabTooltipKeybind(command)} variant="neutral" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="shrink-0"
|
||||
icon={<Icon name="plus" />}
|
||||
onClick={openNewTab}
|
||||
aria-label={language.t("command.session.new")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{(vertical) => (
|
||||
<Show when={vertical().mount} keyed>
|
||||
{(mount) => (
|
||||
<Portal mount={mount}>
|
||||
<TitlebarTabStrip
|
||||
orientation="vertical"
|
||||
tabs={tabsStore}
|
||||
currentTab={currentTab()}
|
||||
onNavigate={(tab, el) => {
|
||||
tabs.select(tab)
|
||||
el?.scrollIntoView({ behavior: "instant", block: "nearest" })
|
||||
}}
|
||||
onClose={(tab) => {
|
||||
const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
|
||||
if (index !== -1) tabsStoreActions.closeTab(index)
|
||||
}}
|
||||
onReorder={(keys) => tabsStoreActions.reorder(keys)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-action="vertical-tabs-new-session"
|
||||
class="mt-1 flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] px-1.5 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base"
|
||||
onClick={openNewTab}
|
||||
aria-label={language.t("command.session.new")}
|
||||
>
|
||||
<Icon name="plus" />
|
||||
{language.t("command.session.new")}
|
||||
</button>
|
||||
</Portal>
|
||||
)}
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={!mobile()}>
|
||||
<div class="flex-1" />
|
||||
</Show>
|
||||
<div class="flex-1" />
|
||||
<TitlebarRight state={rightState()} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -327,7 +327,12 @@ function modelFromLanguage(info: Info, language: LanguageModelV3) {
|
||||
},
|
||||
body: {
|
||||
schema: Schema.Unknown,
|
||||
from: (request) => Effect.succeed(callOptions(request, packageName, info.modelID ?? info.id, optionKey)),
|
||||
from: (request) =>
|
||||
Effect.try({
|
||||
try: () => callOptions(request, packageName, info.modelID ?? info.id, optionKey),
|
||||
catch: (cause) =>
|
||||
cause instanceof AIError ? cause : ProviderShared.invalidRequest("Invalid AI SDK request", cause),
|
||||
}),
|
||||
},
|
||||
with: () => route,
|
||||
model: (input) =>
|
||||
@@ -512,6 +517,8 @@ function userPart(part: ContentPart): UserContent {
|
||||
|
||||
function assistantPart(part: ContentPart): AssistantContent {
|
||||
switch (part.type) {
|
||||
case "compaction":
|
||||
throw ProviderShared.invalidRequest("AI SDK routes cannot replay native provider compaction state")
|
||||
case "text":
|
||||
return [{ type: "text", text: part.text, providerOptions: metadataProviderOptions(part.providerMetadata) }]
|
||||
case "media":
|
||||
|
||||
@@ -10,6 +10,8 @@ import { Provider } from "@opencode-ai/core/provider"
|
||||
import {
|
||||
LLM,
|
||||
AIError,
|
||||
CompactionPart,
|
||||
ProviderID,
|
||||
HttpContext,
|
||||
LLMEvent,
|
||||
Message,
|
||||
@@ -68,6 +70,31 @@ const client = LLMClient.layer.pipe(
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("rejects native provider compaction rather than silently dropping replay state", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* aisdk.hook.sdk((event) => {
|
||||
event.sdk = { languageModel: () => streamModel([]) }
|
||||
})
|
||||
const resolved = yield* aisdk.model(model("@ai-sdk/openai"))
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: resolved,
|
||||
messages: [
|
||||
Message.assistant(
|
||||
CompactionPart.make({
|
||||
provider: ProviderID.make("test-provider"),
|
||||
encrypted: "opaque",
|
||||
}),
|
||||
),
|
||||
],
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
expect(error.message).toContain("cannot replay")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keys language models by package and flattened overlays", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
|
||||
Reference in New Issue
Block a user